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

[C++] I/O 스트림

by 별준 2022. 2. 19.

References

Contents

  • C++ I/O 기능
  • 스트림 (Stream)
  • 문자열 스트림 (String Stream)
  • 파일 스트림 (File Stream)
  • 양방향 I/O (Bidirectional I/O)

프로그램의 주된 기능은 입력(input)을 받아서 결과물(output)을 생성하는 것입니다. 어떠한 output도 생성하지 않는 프로그램은 그다지 쓸모가 없습니다. 프로그래밍 언어마다 나름대로의 I/O 메커니즘을 제공하는데, 언어의 기본 기능에 포함되어 있기도 하고, OS에 특화된 API로 제공되기도 합니다. I/O 시스템은 유연하고 사용하기 쉬울수록 좋습니다. I/O 시스템이 유연하다는 것은 파일이나 콘솔을 비롯한 다양한 디바이스를 통해 입력과 출력을 지원한다는 뜻입니다. 또한 여러 가지 타입으로 된 데이터를 읽고 쓰는 기능도 지원합니다. I/O는 사용자가 잘못된 데이터를 잘못 입력하거나 내부 파일 시스템과 같은 데이터 소스에 접근할 수 없으면 에러를 발생시킵니다. 그래서 좋은 I/O  시스템은 에러에 대처하는 기능도 제공합니다.

 

C 언어에 익숙하다면 printf()와 scanf()에 익숙할 것입니다. printf()와 scanf()는 매우 유연한 I/O 메커니즘입니다. 이 함수들은 정수나 문자 타입, 부동소수점 타입, string 타입만 지원한다는 한계가 있지만 이스케이프 코드와 서식 지정자로 다양한 포맷의 데이터를 읽고 출력하는 기능을 제공합니다. 하지만 뛰어난 I/O 시스템이라 보기에는 다소 아쉬운 점이 몇 가지 있습니다. 무엇보다 에러 처리 기능을 지원하지 않고, 커스텀 데이터 타입을 다룰 정도로 유연하지 않으면 C++과 같은 객체지향 언어와도 어울리지 않습니다.

 

C++은 스트림(stream)이라는 정교한 입출력 메커니즘을 제공합니다. 스트림은 I/O를 유연하고 객체지향적으로 처리합니다. 이번 포스팅에서는 스트림으로 데이터를 입력하고 출력하는 방법에 대해 알아보고, 이러한 스트림 메커니즘으로 사용자 콘솔, 파일, 문자열과 같은 다양한 소스에서 데이터를 읽고 쓰는 방법도 살펴보겠습니다.

 


1. 스트림 Stream

1.1 스트림이란 ?

cout 스트림은 공장의 컨베이어 벨트에 비유할 수 있습니다. 스트림에 변수를 올려 보내면 사용자의 화면인 콘솔(console)에 표시됩니다. 이를 일반화해서 모든 종류의 스트림을 컨베이어 벨트에 표현할 수 있습니다. 스트림마다 방향과 소스(source, 출발지)와 목적지(destination)을 지정할 수 있습니다. 예를 들어, cout 스트림은 출력 스트림(output stream)입니다. 그래서 이 스트림의 방향은 "out"이며, 데이터를 콘솔에 쓰며 이 스트림의 도착지는 "콘솔"입니다. cout에서 c는 '콘솔(console)'이 아니라 '문자(character)'를 의미합니다. 즉, cout은 문자 기반 스트림입니다.

이와 반대로 사용자의 입력을 받는 cin이라는 스트림도 있습니다. 이 스트림의 방향은 "in"이며 관련된 소스는 "콘솔"입니다. cout처럼 cin의 c도 '문자(character)'를 의미합니다. cout과 cin은 C++의 std 네임스페이스에 정의된 스트림 인스턴스이며, <iostream>에 정의되어 있습니다.

이렇게 C++에서 기본적으로 정의된 스트림을 간략하게 정리하면 다음 표와 같습니다.

Stream Description
cin 입력 스트림. 'input console'에 들어온 데이터를 읽습니다.
cout 버퍼를 사용하는 출력 스트림. 데이터를 'output console'에 씁니다.
cerr 버퍼를 사용하지 않는 출력 스트림. 데이터를 'error console'에 씁니다. 'error console'과 'output console'이 같을 때가 많습니다.
clog 버퍼를 사용하는 cerr

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

 

방금 살펴본 입출력 스트림에 대한 확장 문자(wide character) 버전(wcin, wcout, wcerr, wclog)도 있습니다. 확장 문자는 중국어와 같은 언어로 작업할 때 사용됩니다.

 

스트림에서 중요한 또 다른 측면은 데이터에 현재 가리키는 위치(현재 위치, current position)도 함께 담고 있다는 것입니다. 스트림에서 현재 위치란 다음번에 읽기 또는 쓰기 연산을 수행할 위치를 의미합니다.

 

1.2 Stream Sources and Destination

스트림이란 개념은 데이터를 입력받거나 출력하는 객체라면 어떤 것에도 적용할 수 있습니다. 네트워크 관련 클래스를 스트림 기반으로 작성할 수도 있고, MIDI 장치에 접근하는 부분도 스트림으로 구현할 수 있습니다. C++에서는 흔히 스트림의 출발지와 목적지로 콘솔, 파일, 스트링(string)을 사용합니다.

 

콘솔에서 사용자가 입력한 내용을 스트림으로 처리하는 것은 아마 많이 보셨을 것입니다. 콘솔 입력 스트림(console input stream)을 이용하면 실행 시간에 사용자로부터 입력을 받을 수 있어서 프로그램을 보다 인터렉티브(interactive)하게 만들 수 있습니다. 콘솔 출력 스트림(console output stream)을 사용하면 사용자에게 처리 결과를 출력하거나 피드백을 제공할 수 있습니다.

 

파일 스트림(File Stream)은 이름에서 의미하는 바와 같이 파일 시스템에서 데이터를 읽고 쓰는 스트림입니다. 파일 입력스트림(file input stream)은 설정 데이터나 저장된 파일을 읽거나, 파일 형태의 데이터를 배치 방식으로 처리할 때 유용합니다. 파일 출력 스트림(file output stream)은 프로그램의 상태를 저장하거나 결과를 출력하는 데 유용합니다. 파일 스트림은 C 언어의 fprintf(), fwrite(), fputs() 같은 출력 함수와 fscanf(), fread(), fgets() 같은 입력 함수의 기능을 모두 포함합니다.

 

문자열 스트림(string stream)은 string 타입에 스트림 개념을 적용한 것입니다. 문자열 스트림을 이용하면 문자 데이터도 스트림처럼 다룰 수 있습니다. 문자열 스트림은 주로 string 클래스에 있는 메소드의 기능을 쉽게 사용하는 역할을 하지만, string 클래스를 직접 사용할 때보다 훨씬 편하고 효율적이며 최적화할 여지도 많습니다. 문자열 스트림은 C 언어의 sprintf(), sprintf_s(), sscanf() 같은 함수를 제공합니다.

 

1.3 Output with Streams

스트림으로 출력하는 방법은 아마 잘 아실 거라 생각이 됩니다. 간단한 기본 내용을 살펴보고 고급 기능들에 대해 알아보도록 하겠습니다.

 

1.3.1 Output Basics

출력 스트림은 <ostream> 헤더 파일에 정의되어 있습니다. 프로그램을 작성할 때 흔히 include하는 <iostream> 헤더에는 입력과 출력 스트림이 모두 정의되어 있습니다. <iostream>에는 미리 정의된 스트릶 인스턴스인 cout, cin, cerr, clog와 각각에 대한 와이드 문자 버전도 있습니다.

 

출력 스트림을 사용하는 가장 간편한 방법은 << 연산자를 이용하는 것입니다. int, 포인터, double, 문자를 비롯한 C++의 기본 타입은 모두 << 연산자로 출력할 수 있습니다. 또한 C++의 string 클래스뿐만 아니라 C 스타일 스트링도 <<으로 처리할 수 있습니다.

<< 연산자의 사용 예시를 몇 가지 들면 다음과 같습니다.

int i{ 7 };
std::cout << i << std::endl;

char ch{ 'a' };
std::cout << ch << std::endl;

std::string myString{ "Hello World." };
std::cout << myString << std::endl;

 

cout 스트림은 C++에서 기본으로 제공하는 build-in 스트림으로서, 콘솔(or standard output)에 값을 write합니다. 여러 조각으로 된 데이터를 하나로 합쳐서 출력할 때도 << 연산자를 사용합니다. operator<<가 스트림에 대한 레퍼런스를 리턴하기 때문에 그 결과를 동일한 스트림의 << 연산자에 연달아 적용할 수 있습니다.

예를 들면 다음과 같습니다.

int j{ 11 };
std::cout << "The value of j is " << j << "!" << std::endl;

 

C++ 스트림은 C 스타일의 문자열에 담긴 '\n'과 같은 이스케이프 시퀀스(escape sequences)도 정확히 처리합니다. 이때 줄바꿈하는 부분을 '\n' 대신 std::endl로 표현해도 됩니다. '\n'는 단순히 새로운 line을 시작하는 데 반해 endl은 버퍼를 비우기(flush)도 합니다. 단, 비우는 연산이 너무 많으면 성능이 떨어질 수 있으니 endl을 사용할 때는 주의해야 합니다. 예를 들어 텍스트를 여러 줄에 걸쳐 출력하면서 매번 버퍼를 비우는 작업을 한 문장으로 표현하면 다음과 같습니다.

std::cout << "Line 1" << std::endl << "Line 2" << std::endl << "Line 3" << std::endl;

 

endl은 destination buffer를 flush 합니다. 따라서 성능이 중요한 코드에서는 주의해야 합니다.

 

1.3.2 Methods of Output Streams

출력 스트림에서 가장 대표적인 연산자는 <<입니다. 이 연산자는 단순히 출력하는 기능 외에도 여러 가지 기능을 제공합니다. <ostream> 헤더 파일에서 << 연산자를 정의한 코드를 보면 여러 가지 데이터 타입에 대해 오버로딩된 버전과 몇 가지 유용한 public 메소드가 있습니다.

 

  • put() 과 write()

put()write()는 raw output 메소드입니다. 출력 동작을 갖춘 객체나 변수를 받는 대신, put()은 문자 하나를 인수로 받고, write()는 문자 배열 하나를 인수로 받습니다. 이 메소드에 전달된 데이터는 특정한 포맷을 적용하거나 데이터의 내용을 가공하지 않고 전달된 상태 그대로 출력됩니다. 예를 들어 << 연산자를 사용하지 않고 C 스타일 스트링을 콘솔에 출력하려면 다음과 같이 작성합니다.

const char* test{ "hello there\n" };
std::cout.write(test, strlen(test));

put() 메소드로 문자 하나를 콘솔에 출력하는 방법은 다음과 같습니다.

std::cout.put('a');

 

  • flush()

출력 스트림에 데이터를 쓰는 즉시 목적지에 전달되지 않을 수도 있습니다. 일반적으로 출력 스트림은 들어온 데이터를 곧바로 쓰지 않고 버퍼(buffer)에 잠시 보관합니다. 그렇게 하면 성능을 높일 수 있습니다. 목적지가 파일과 같은 스트림일 때는 한 문자씩 처리하기보다는 블록 단위로 묶어서 처리하는 것이 훨씬 효율적입니다. 스트림은 다음과 같은 조건을 만족할 때 그동안 쌓아둔 데이터를 모두 내보내고 버퍼를 비웁니다(flush).

  • std::endl manipulator에 도달할 때
  • 스트림이 스코프를 벗어나 소멸될 때
  • 스트림 버퍼가 가득찼을 때
  • 명시적으로 버퍼를 비우라고 스트림에게 전달할 때(flush())
  • 출력 스트림에 대응되는 입력 스트림으로부터 입력이 요청될 때(예를 들어 cin으로 입력을 받으면 cout의 버퍼를 비움)

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

std::cout << "abc";
std::cout.flush(); // abc is written to the console.
std::cout << "def";
std::cout.flush(); // def is written to the console.

visual studio community 2019의 경우에는 flush()하기 전에 cout으로 출력이 되고 있습니다. 우분투에서 g++로 컴파일하여 실행했을 때는 flush()할 때 버퍼의 데이터를 출력합니다.

(아마 컴파일러마다 차이가 있는 것 같습니다.)

 

1.3.3 Handling Output Errors

Output errors가 발생하는 경우는 다양합니다. 예를 들어 존재하지 않는 파일을 열거나, 디스크가 꽉 차서 쓰기 연산을 처리할 수 없을 때 에러가 발생합니다. 지금까지 살펴본 스트림 예제는 너무 간단해서 이렇게 에러가 발생하는 경우를 신경쓰지 않았습니다. 하지만 코드를 작성할 때는 발생 가능한 모든 종류의 에러에 항상 대처하는 것이 바람직합니다.

 

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

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

good() 메소드를 이용하면 스트림의 상태 정보를 조회할 수 있습니다. 하지만 사용할 수 없는 상태일 때는 그 원인을 구체적으로 알려주지는 않습니다. 이런 정보는 bad() 메소드로 자세히 살펴볼 수 있습니다. bad() 메소드가 true를 리턴한다는 말은 심각한 에러가 발생했다는 뜻입니다. 반면 파일의 끝에 도달했는지 알려주는 eof()가 true라는 것은 심각한 상태가 아닙니다. 또한 fail() 메소드를 사용하면 최근 수행한 연산에 에러가 발생했는지 확인할 수 있습니다. 그러나 그 뒤에 일어날 연산의 상태는 알려주지 않기 때문에 fail()의 리턴값에 관계없이 후속 연산이 성공적으로 수행할 수도 있고 아닐 수도 있습니다. 예를 들어, 출력 스트림에 대해 flush()를 호출한 뒤 fail()을 호출하면 바로 직전의 flush() 연산이 성공했는지 확인할 수 있습니다.

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

스트림을 bool 타입으로 변환하는 연산자도 있습니다. 이 연산자는 !fail()을 호출할 때와 똑같은 결과를 리턴합니다. 따라서 위의 코드를 다음과 같이 작성해도 됩니다.

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

 

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

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

 

스트림에 문제가 있으면 예외를 발생하도록 만들 수 있습니다. ios_base::failure 예외를 처리하도록 catch 구문을 작성하면 됩니다. 이 예외에 대해 what() 메소드를 호출하면 현재 발생한 에러에 대한 정보를 볼 수 있습니다. 또한 code()를 호출하면 에러 코드를 볼 수 있습니다. 하지만 이 정보가 얼마나 쓸모 있는지는 주로 표준 라이브러리의 구현마다 다릅니다.

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

 

스트림의 에러 상태를 초기화하려면 clear() 메소드를 사용하면 됩니다.

std::cout.clear();

 

콘솔 출력 스트림은 파일 입출력 스트림에 비해서 에러를 검사할 일이 적습니다. 

위에서 소개한 메소드들은 다른 스트림에서도 똑같이 적용할 수 있는데, 아래에서 다른 스트림 타입에 대해 설명할 때도 위 내용들을 참조하도록 하겠습니다.

 

1.3.4 Output Manipulators

C++의 스트림은 단순히 데이터만 전달하는 것이 아니라 Manipulator(조작자, )라는 객체를 받아서 스트림의 동작을 변경할 수도 있습니다. 이때 스트림의 동작을 변경하는 작업만 할 수도 있고, 스트림에 데이터를 전달하면서 동작도 변경할 수 있습니다.

 

앞에서 본 std::endl이 바로 스트림 매니퓰레이터입니다. std::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 : 지정한 문자열을 따옴표로 감싸고, 문자열 안에 있던 인용부호를 이스케이프 문자로 변환합니다. 이 매니퓰레이터도 파라미터를 받습니다.

방금 소개한 매니퓰레이터는 모두 한 번 설정되면 명시적으로 리셋하기 전까지 다음 출력에 계속 반영됩니다. 단, setw는 바로 다음 출력에만 적용됩니다.

다음 예제 코드는 위에서 소개한 매니퓰레이터로 출력 형태를 커스터마이징합니다.

#include <iostream>
#include <ios>
#include <iomanip>

int main()
{
    // Boolean values
    bool myBool{ true };
    std::cout << "This is the default: " << myBool << std::endl;
    std::cout << "This should be true: " << std::boolalpha << myBool << std::endl;
    std::cout << "This should be 1: " << std::noboolalpha << myBool << std::endl;

    // Simulate printf-style "%6d" with streams
    int i{ 123 };
    printf("This should be ' 123': %6d\n", i);
    std::cout << "This should be ' 123': " << std::setw(6) << i << std::endl;

    // Simulate printf-style "%06d" with streams
    printf("This should be '000123': %06d\n", i);
    std::cout << "This should be '000123': " << std::setfill('0') << std::setw(6) << i << std::endl;

    // Fill with *
    std::cout << "This should be '***123': " << std::setfill('*') << std::setw(6) << i << std::endl;

    // Reset fill character
    std::cout << std::setfill(' ');

    // Floating-point values
    double dbl{ 1.452 };
    double dbl2{ 5 };
    std::cout << "This should be ' 5': " << std::setw(2) << std::noshowpoint << dbl2 << std::endl;
    std::cout << "This should be @@1.452: " << std::setw(7) << std::setfill('@') << dbl << std::endl;

    // Reset fill character
    std::cout << std::setfill(' ');

    // Instructs cout to start formatting numbers according to your location.
    // Chapter 21 explains the details of the imbue() call and the locale object.
    std::cout.imbue(std::locale{ "" });

    // Format numbers according to your location
    std::cout << "This is 1234567 formatted according to your location: " << 1234567
        << std::endl;

    // Monetary value. What exactly a monetary value means depends on your
    // location. For example, in the USA, a monetary value of 120000 means 120000
    // dollar cents, which is 1200.00 dollars.
    std::cout << "This should be a monetary value of 120000, "
        << "formatted according to your location: "
        << std::put_money("120000") << std::endl;

    // Date and time
    time_t t_t{ time(nullptr) }; // Get current system time.
    tm t;
    localtime_s(&t, &t_t); // Convert to local time.
    std::cout << "This should be the current date and time "
        << "formatted according to your location: "
        << std::put_time(&t, "%c") << std::endl;

    // Quoted string
    std::cout << "This should be: \"Quoted string with \\\"embedded quotes\\\".\": "
        << std::quoted("Quoted string with \"embedded quotes\".") << std::endl;
}

위 코드를 실행하면 다음의 출력 결과를 확인할 수 있습니다.

 

매니퓰레이터를 사용하고 싶지 않다면 다른 방식으로 같은 효과를 낼 수도 있습니다. 스트림마다 매니퓰레이터에 대응되는 메소드(ex, precision())를 제공합니다. 예를 들면 다음과 같습니다.

std::cout << "This should be '1.2346': " << std::setprecision(5) << 1.23456789 << std::endl;

위 문장 대신 다음과 같이 precision()을 호출하도록 작성해도 됩니다. 이 메소드는 이전에 설정된 값을 리턴하기 때문에 그 값을 저장해두면 언제든지 이전 상태로 되돌릴 수 있습니다.

std::cout.precision(5);
std::cout << "This should be '1.2346: " << 1.23456789 << std::endl;

 

 

1.4 Input with Streams

입력 스트림을 이용하면 일반 데이터뿐만 아니라 구조화된 데이터도 쉽게 읽을 수 있습니다. 이번에는 콘솔 입력 스트림인 cin으로 입력을 처리하는 방법에 대해 알아보도록 하겠습니다.

 

1.4.1 Input Basics

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

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

 

>> 연산자의 기본 설정에 따르면 공백을 기준으로 입력된 값을 토큰화합니다. 예를 들어, 위의 코드를 실행한 뒤 콘솔에 'hello there'을 입력하면 첫 번째 공백문자(스페이스) 이전의 문자들만 userInput 변수에 담기며, 출력 결과는 다음과 같습니다.

C++ 에서 공백(whitespace)는 space(' '), from feed('\f'), line feed('\n'), carriage return('\r'), horizontal tab('\t'), vertical tab('\v') 입니다.

이 문제에 대한 한 가지 해결 방법은 get() 메소드를 사용하는 것입니다. 그러면 입력값에 공백을 담을 수 있습니다. 이 메소드에 대한 자세한 내용은 아래에서 언급하도록 하겠습니다.

 

>> 연산자는 << 연산자와 마찬가지로 다양한 타입을 지원합니다. 예를 들어, 정수를 읽으려면 다음처럼 변수의 타입만 바꾸면 됩니다.

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

 

또한, 타입이 다른 값을 동시에 받을 수 있습니다. 예를 들어 다음 코드는 레스토랑 예약 시스템에서 사용하는 함수를 표현한 것인데, 예약자의 성과 참석 인원을 사용자로부터 입력받습니다.

void getReservationData()
{
    std::string guestName;
    int partySize;
    std::cout << "Name and numbere of quests: ";
    std::cin >> gusetName >> partySize;
    std::cout << "Thank you, " << guestName << ".\n";
    if (partySize > 10)
        std::cout << "An extra gratuity will apply.\n";
}

 

앞서 설명했듯이 기본적으로 >> 연산자는 공백을 기준으로 값을 토큰화합니다. 따라서 getReservationData() 함수는 공백이 들어간 이름을 입력받지 못합니다. 이를 해결하기 위해 unget()을 활용할 수 있는데 자세한 방법은 아래에서 설명하도록 하겠습니다. 위 코드에서 cout은 endl이나 flush()로 버퍼를 명시적으로 비우지 않아도 전달한 텍스트가 콘솔에 표시되는데, 바로 뒤에 cin을 사용해서 cout 버퍼를 즉시 비우기 때문입니다. cin과 cout은 이런 식으로 연결되어 있습니다.

 

 

1.4.2 Handling Input Erros

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

while (std::cin) { /* ... */ }

아래처럼 데이터를 동시에 입력할 수도 있습니다.

while (std::cin >> ch) { /* ... */ }

 

출력 스트림과 마찬가지로 입력 스트림에 대해서도 good(), bad(), fail() 메소드를 호출할 수 있습니다. 또한 스트림이 끝에 도달하면 true를 리턴하는 eof() 메소드도 사용할 수 있습니다. 입력 스트림의 good()과 fail()은 출력 스트림과 마찬가지로 파일 끝에 도달하면 false를 리턴하며, 다음의 관계가 성립합니다.

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

이처럼 데이터를 읽을 때마다 항상 스트림 상태를 검사하는 습관을 들이는 것이 좋습니다.

 

다음 예는 스트림에서 데이터를 읽는 과정에 발생하는 에러에 대처하기 위해 흔히 사용하는 패턴을 보여줍니다. 아래 코드는 표준 입력으로 주어진 숫자를 읽어서 스트림이 EOF에 도달할 때까지 합산한 결과를 화면에 표시합니다. 여기서 커맨드 라인 환경에서 사용자가 EOF를 입력하는 방법에 주의합니다. 유닉스와 리눅스에서는 Ctrl+D를 사용하지만 윈도우에서는 Ctrl+Z를 사용합니다.

#include <iostream>
#include <string>

int main()
{
    std::cout << "Enter numbers on separate lines to add. "
        << "Use Control+D to finish (Control+Z in Windows)." << std::endl;
    int sum = 0;

    if (!std::cin.good()) {
        std::cerr << "Standard input is in a bad state!" << std::endl;
        return 1;
    }

    int number;
    while (!std::cin.bad()) {
        std::cin >> number;
        if (std::cin.good()) {
            sum += number;
        }
        else if (std::cin.eof()) {
            break;
        }
        else if (std::cin.fail()) {
            // Failure!
            std::cin.clear(); // Clear the failure state.
            std::string badToken;
            std::cin >> badToken; // Consum the bad input.
            std::cerr << "WARNING: Bad input encountered: " << badToken << std::endl;
        }
    }

    std::cout << "The Sum is " << sum << std::endl;
}

위 코드의 실행 결과는 다음과 같습니다.

 

1.4.3 Input Methods

출력 스트림과 마찬가지로 입력 스트림도 >> 연산자보다 lower level로 접근하는 메소드를 제공합니다.

 

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

std::string readName(std::istream& stream)
{
    std::string name;
    while (stream) { // Or: while (!stream.fail()) {
        int next{ stream.get() };
        if (!stream || next == std::char_traits<char>::eof())
            break;
        name += static_cast<char>(next); // Append character
    }

    return name;
}

위의 readName() 함수에서 몇 가지 주목해야할 점이 있습니다.

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

 

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

std::string readName(std::istream& stream)
{
    std::string name;
    char next;
    while (stream.get(next)) {
        name += next;
    }
    return name;
}

 

일반적으로 입력 스트림은 한 방향으로만 진행하는 컨베이너 벨트와 같습니다. 여기에 올라간 데이터는 변수로 전달됩니다. 그런데 unget() 메소드는 데이터를 다시 입력 소스 방향으로 보낼 수 있다는 점에서 조금 다릅니다.

 

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

 

앞에서 본 getReservationName() 함수는 공백이 담긴 이름을 입력 받을 수 없었습니다. 하지만 다음과 같이 unget()을 이용하면 이름에 공백을 담을 수 있습니다. 이 코드는 문자를 하나씩 읽어서 그 문자가 숫자인지 확인합니다. 숫자가 아니라면 guestName에 추가하고, 숫자면 unget()으로 스트림을 되돌린 후 반복문을 빠져나와서 >> 연산자로 partySize에 정수를 입력합니다. 그리고 나서 입력 매니퓰레이터인 noskipws로 스트림이 공백을 건너뛰지 말고 일반 문자처럼 설정합니다.

void getReservationData()
{
    std::string guestName;
    int partySize{ 0 };
    // Read characters until we find a digit
    char ch;
    std::cin >> std::noskipws;
    while (std::cin >> ch) {
        if (isdigit(ch)) {
            std::cin.unget();
            if (std::cin.fail()) {
                std::cout << "unget() failed" << std::endl;
            }
            break;
        }
        guestName += ch;
    }
    // Read partySize, if the stream is not in error state
    if (std::cin)
        std::cin >> partySize;
    if (!std::cin) {
        std::cerr << "Error getting party size." << std::endl;
        return;
    }

    std::cout << "Thank you '" << guestName << "', party of " << partySize << std::endl;
    if (partySize > 10) {
        std::cout << "An extra gratuity will apply." << std::endl;
    }
}

 

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

int main()
{
    char c;
    std::cin >> c;
    std::cout << "Retrieved " << c << " before putback('e')." << std::endl;

    std::cin.putback('e'); // 'e' will be the next character read off the stream.
    std::cin >> c;
    std::cout << "Retrieved " << c << " after putback('e')." << std::endl;
}

위 코드를 실행하면 다음의 출력을 확인할 수 있습니다.

 

peek() 메소드는 get() 호출할 때 리턴될 값을 미리 살펴볼 때 사용합니다. 컨베이너 벨트에 비유하면 현재 처리할 지점에 있는 물건을 건드리지 않고 눈으로 확인만 하는 것입니다.

 

peek()는 값을 읽기 전에 먼저 봐야할 상황에서 유용하게 사용될 수 있습니다. 예를 들어 다음 코드에서 getReservation() 함수는 이름에 공백을 넣을 수 있도록 구현하기 위해 unget() 대신 peek()을 사용합니다.

void getReservationData()
{
    std::string guestName;
    int partySize{ 0 };
    // Read characters until we find a digit
    char ch;
    std::cin >> std::noskipws;
    while (true) {
        // 'peak' at next character
        char ch{ static_cast<char>(std::cin.peek()) };
        if (!std::cin)
            break;
        if (isdigit(ch)) {
            // next character will be a digit, so stop the loop
            break;
        }
        // next character will be non-digit, so read it
        std::cin >> ch;
        if (!std::cin)
            break;
        guestName += ch;
    }
    // Read partySize, if the stream is not in error state
    if (std::cin)
        std::cin >> partySize;
    if (!std::cin) {
        std::cerr << "Error getting party size." << std::endl;
    }

    std::cout << "Thank you " << guestName << ", party of " << partySize << std::endl;
    if (partySize > 10) {
        std::cout << "An extra gratuity will apply." << std::endl;
    }
}

 

프로그램을 작성하다 보면 입력 스트림에서 데이터를 한 줄씩 읽는 경우가 많습니다. 이를 위해 getline()이란 메소드를 별도로 제공합니다. 이 메소드는 미리 설정한 버퍼가 가득 채워질 때까지 문자 한 줄을 읽습니다. 이때 한 줄의 끝을 나타내는 '\0'(EOL, end-of-line) 문자도 버퍼의 크기에 포함됩니다. 따라서 다음 코드는 cin으로부터 kBufferSize-1개의 문자를 읽거나 EOL 문자가 나올 때까지 읽기 연산을 수행합니다.

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

 

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

 

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

 

C++의 string에서 사용할 수 있는 std::getline()이란 함수도 있습니다. 이 함수는 <string> 헤더 파일의 std 네임스페이스 아래에 정의되어 있습니다. 이 함수는 스트림 레퍼런스와 string 레퍼런스를 파라미터로 받습니다. std::getline()을 사용하면 버퍼의 크기를 지정하지 않아도 된다는 장점이 있습니다.

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

 

getline() 메소드와 std::getline() 함수는 모두 옵션으로 마지막 파라미터에 구분자(delimiter)를 받습니다. 기본값은 \n 입니다. 이 구분자를 변경하면 이 함수들은 주어진 구분자가 나올 때까지 문자 한 줄을 읽습니다. 예를 들어 다음 코드는 @ 문자가 나올 때까지 한 줄을 읽습니다.

int main()
{
    std::cout << "Enter multiple lines of text. "
        << "Use an @ character to signal the end of the text.\n> ";
    std::string myString;
    std::getline(std::cin, myString, '@');
    std::cout << "Read text: \"" << myString << "\"\n";
}

위 코드는 다음과 같이 사용하고 출력될 수 있습니다.

 

1.4.4 Input Manipulators

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

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

입력은 로케일(locale) 설정에 영향을 받는데, 예를 들어 다음 코드처럼 cin에 시스템 로케일을 설정할 수 있습니다.

이번 포스팅에서 다룰 주제는 아니라 자세한 설명은 넘어가도록 하겠습니다.

std::cin.imbue(locale(""));
int i;
std::cin >> i;

시스템 로케일이 U.S. English일 때, 1,000을 입력하면 1000으로 읽고 1.000을 입력하면 1로 읽습니다. 반면 시스템 로케일이 Dutch Belgium일 때 1.000을 입력하면 1000으로 읽고, 1,000을 입력하면 1로 읽습니다. 자릿수를 표시하는 콤마(,)없이 1000만 입력하면 1000이란 값으로 읽는 점은 똑같습니다.

 

1.5 Input and Output with Objects

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

이에 관해서는 다음에 연산자 오버로딩에 대한 포스팅에서 자세히 다루도록 하겠습니다.

 

 


2. String Streams

문자열 스트림이란 string에 스트림 개념을 추가한 것입니다. 이를 통해 텍스트 데이터를 메모리에서 스트림 형태로 표현하는 in-memory stream을 만들 수 있습니다. 예를 들어, GUI 어플리케이션에서 콘솔이나 파일이 아닌 스트림으로부터 텍스트 데이터를 구성한 뒤 이를 메세지 박스나 편집 컨트롤과 같은 GUI 요소로 결과를 출력할 수 있습니다. 또 다른 예로 문자열 스트림을 현재 위치에 대한 정보와 함께 여러 함수에 전달해서 다양한 작업을 연속적으로 처리할 수 있습니다. 문자열 스트림은 기본적으로 토큰화 기능을 제공하기 때문에 텍스트 파싱 작업에도 유용합니다.

 

string에 데이터를 쓸 때는 std::ostringstream 클래스를, 반대로 string에서 데이터를 읽을 때는 std::istringstream 클래스를 사용합니다. 둘 다 <sstream> 헤더 파일에 정의되어 있습니다. ostringstream과 istringstream은 각각 ostream과 istream을 상속하므로 기존 입출력 스트림처럼 다룰 수 있습니다.

 

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

std::cout << "Enter tokens. "
    << "Control+D (Unix) or Control+Z (Windows) followed by Enter to end."
    << std::endl;
std::ostringstream outStream;
while (std::cin) {
    std::string nextToken;
    std::cout << "Next token: ";
    std::cin >> nextToken;
    if (!std::cin || nextToken == "done")
        break;
    outStream << nextToken << "\t";
}
std::cout << "The end result is: " << outStream.str();

 

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

class Muffin
{
public:
    virtual ~Muffin() = default;
    const std::string& getDescription() const { return m_description; }
    void setDescription(std::string description)
    {
        m_description = std::move(description);
    }
    int getSize() const { return m_size; }
    void setSize(int size) { m_size = size; }
    bool hasChocolateChips() const { return m_hasChocolateChips; }
    void setHasChocolateChips(bool hasChips)
    {
        m_hasChocolateChips = hasChips;
    }
private:
    std::string m_description;
    int m_size{ 0 };
    bool m_hasChocolateChips{ false };
};

Muffin createMuffin(std::istringstream& stream)
{
    Muffin muffin;
    // Assume data is properly formatted;
    // Description size chips

    std::string description;
    int size;
    bool hasChips;

    // Read all three values. Note that chips is represented
    // by the strings "true" and "false"
    stream >> description >> size >> std::boolalpha >> hasChips;
    if (stream) { // Reading was successful
        muffin.setSize(size);
        muffin.setDescription(description);
        muffin.setHasChocolateChips(hasChips);
    }
    return muffin;
}

 

표준 C++ string만 사용하지 않고 문자열 스트림을 함께 활용하면 데이터를 읽거나 쓸 지점(curretn position)을 알 수 있어서 좋습니다. 또한 문자열 스트림은 다양한 매니퓰레이터와 로케일을 지원하므로 string보다 포맷을 보다 융통성있게 다룰 수 있습니다.

 


3. File Streams

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

 

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

 

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

참고로 여기 나온 모드를 조합하여 지정할 수 도 있습니다. 예를 들어 출력할 파일을 바이너리 모드로 열고, 기존 데이터를 모두 삭제하고 싶다면 다음과 같이 지정합니다.

std::ios_base::out | std::ios_base::binary | std::ios_base::trunc

 

ifstream에 in을 명시적으로 지정하지 않아도 기본적으로 ios_base::in 모드가 설정됩니다. ofstream도 마찬가지로 명시적으로 out을 지정하지 않아도 기본적으로 ios_base::out 모드가 설정됩니다.

 

다음 코드는 test.txt 파일을 열고, 프로그램의 인수로 주어진 값을 출력합니다. ifstream과 ofstream 소멸자는 자동으로 오픈한 파일을 닫습니다. 따라서 close()를 직접 호출하지 않아도 됩니다.

int main(int argc, char* argv[])
{
    std::ofstream outFile{ "test.txt", std::ios_base::trunc };
    if (!outFile.good()) {
        std::cerr << "Error while opening output file!" << std::endl;
        return -1;
    }
    outFile << "There were " << argc << " arguments to this program." << std::endl;
    outFile << "They are " << std::endl;
    for (int i = 0; i < argc; i++) {
        outFile << argv[i] << std::endl;
    }
}

 

3.1 Text Mode vs. Binary Mode

파일 스트림은 기본적으로 텍스트 모드(text mode)로 파일을 오픈합니다. 만약 ios_base::binary로 지정한다면 파일을 바이너리 모드(binary mode)로 파일을 오픈합니다.

 

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

 

텍스트 모드로 열면 파일에서 \n이 나올 때마다 한 줄씩 읽거나 씁니다. 이때 파일에서 줄끝(EOL, end of line)을 나타내는 문자는 OS마다 다릅니다. 예를 들어 윈도우에서는 \n이 아닌 \r\n으로 줄끝을 표현합니다. 그래서 파일을 텍스트 모드로 열고 각 줄이 \n으로 끝나도록 작성해도 파일에 저장할 때는 자동으로 \n을 모두 \r\n으로 변환합니다. 마찬가지로 파일을 읽을 때도 \r\n으로 표현된 부분을 모두 \n으로 자동으로 변환합니다.

 

3.2 seek() and tell()

입력과 출력 스트림은 모두 seek와 tell 메소드를 갖고 있습니다.

seek 메소드는 입력 또는 출력 스트림에서 현재 위치를 원하는 지점으로 옮기는 작업을 수행합니다. seek에는 여러 가지 버전이 있는데, 입력 스트림에 대한 seek 메소드는 seekg() 입니다 (g는 get을 의미). 그리고 출력 스트림에 대한 seek 메소드는 seekp() 입니다 (p는 put을 의미). seek를 하나로 표현하지 않고 seekg()와 seekp()로 구분한 이유는 파일 스트림처럼 입력과 출력을 모두 가질 때가 있기 때문입니다. 이럴 때는 읽는 위치와 쓰는 위치를 별도로 관리해야 합니다. 이를 양방향(bidirectional) I/O라고 부르며 뒤에서 조금 더 자세하게 설명하도록 하겠습니다.

 

seekg()와 seekp() 메소드는 각각 두 개의 오버로드가 있습니다. 하나는 절대 위치를 나타내는 인수 하나만 받아서 그 위치로 이동합니다. 다른 하나는 오프셋과 위치를 인수로 받아서 지정한 위치를 기준으로 떨어진 거리(오프셋)로 이동합니다. 이때 위치는 std::streampos 타입이고, 오프셋은 std::streamoff 타입입니다.

C++에 미리 정의된 위치는 다음과 같습니다.

예를 들어, 다음과 같이 매개변수가 하나인 seekp()에 ios_base::beg 상수를 지정하면 출력 스트림의 위치를 스트림의 시작 지점으로 옮길 수 있습니다.

outStream.seekp(std::ios_base::beg);

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

inStream.seekg(std::ios_base::beg);

 

인수가 두 개인 버전은 스트림의 위치를 상대적으로 지정합니다. 첫 번째 인수는 이동할 위치의 양을 지정하고, 두 번째 인수는 시작 지점을 지정합니다. 파일의 시작점을 기준으로 위치를 이동하려면 ios_base::beg 상수를 지정합니다. ios_base::end를 사용하면 파일의 끝을 기준으로 위치를 이동할 수 있습니다. 또한 현재 위치를 기준으로 이동하고 싶다면 ios_base::cur을 사용합니다.

예를 들어 다음 코드는 스트림의 시작점에서 두 바이트만큼 이동합니다.

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

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

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

 

tell 메소드를 이용하면 스트림의 현재 위치를 알아낼 수 있습니다. seek 메소드와 마찬가지로 tellg()tellp()가 있습니다. 이 메소드는 현재 위치를 std::streampos 타입의 값으로 리턴합니다. seek 메소드를 호출하거나 tell을 다시 호출하기 전에 현재 위치를 기억하고 싶다면 앞서 tell 에서 리턴한 값을 저장해둡니다.

 

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

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

 

지금까지 설명한 메소드를 모두 사용하는 예제를 살펴보겠습니다. 이 예제는 다음의 테스트를 수행하면서 test.out에 데이터를 씁니다.

  1. "54321"라는 문자열을 파일에 출력
  2. 스트림의 현재 위치가 5인지 확인
  3. 출력 스트림의 위치를 2로 이동
  4. 위치가 2인 지점에 0을 쓴 뒤 출력 스트림을 닫음
  5. test.out 파일에 대한 입력 스트림을 오픈
  6. 첫 번째 토큰을 정수 타입의 값으로 읽음
  7. 읽은 값이 54021인지 확인
std::ofstream fout{ "test.out" };
if (!fout) {
    std::cerr << "Error opening test.out for writing\n";
    return -1;
}

// 1. Output the string "54321"
fout << "54321";

// 2. Verify that the marker is at position 5
std::streampos curPos{ fout.tellp() };
if (curPos == 5) {
    std::cout << "Test passed: Currently at position 5\n";
}
else {
    std::cout << "Test failed: Not at position 5\n";
}

// 3. Move to position 2 in the output stream
fout.seekp(2, std::ios_base::beg);

// 4. Output a 0 in position 2 and close the output stream
fout << 0;
fout.close();

// 5. Open an input stream on test.out
std::ifstream fin{ "test.out" };
if (!fin) {
    std::cerr << "Error opening test.out for reading\n";
    return 1;
}

// 6. Read the first token as an integer
int testVal;
fin >> testVal;
if (!fin) {
    std::cerr << "Error reading from file\n";
    return 1;
}

// 7. Confirm that the value is 54021
const int expected{ 54021 };
if (testVal == expected) {
    std::cout << "Test passed: Value is " << expected << std::endl;
}
else {
    std::cout << "Test failed: Value is not " << expected << " (it was " << testVal << ")\n";
}

 

3.3 Linking Streams Together

입력 스트림과 출력 스트림은 언제든지 flush-on-access 방식으로 서로 연결될 수 있습니다. 다시 말하면, 입력 스트림을 출력 스트림에 연결한 뒤 입력 스트림에서 데이터를 읽으면, 연결된 출력 스트림이 flush 됩니다. 이러한 동작은 모든 종류의 스트림에서 가능하며 파일 스트림끼리 연결할 때 특히 유용합니다.

 

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

 

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

std::ifstream inFile{ "test.txt" };
std::ofstream outFile{ "output.txt" };
// Set up a link between inFile and outFile
inFile.tie(&outFile);
// Output some text to outFile. Normally, this would
// not flush becuase std::endl is not send
outFile << "Hello there!\n";
// outFile has NOT been flushed.
// Read some text from inFile. This will trigger flush() on outFile.
std::string nextToken;
inFile >> nextToken;
// outFile HAS been flushed

 

flush() 메소드는 ostream 베이스 클래스에 정의되어 있습니다. 따라서 출력 스트림을 또 따른 출력 스트림에 링크시킬 수 있습니다.

outFile.tile(&anotherOutputFile);

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

 

스트림에 연결된 대표적인 예로 cout과 cin을 연결해서 cin에 데이터를 입력할 때마다 cout을 자동으로 flush하는 경우가 있습니다. cerr와 cout도 서로 연결할 수 있습니다. 다시 말해 cerr에 출력할 때마다 cout을 flush할 수 있다는 것입니다. 반면 clog 스트림은 cout에 연결될 수 없습니다. 와이드 문자 버전의 스트림도 같은 방식으로 연결할 수 있습니다.

 


4. Bidrectional I/O

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

 

양방향 스트림은 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(std::string_view filename, int id, std::string_view newNumber)
{
    std::fstream ioData{ filename.data() };
    if (!ioData) {
        std::cerr << "Error while opening file " << filename << std::endl;
        return false;
    }

    // Loop until the end of file
    while (ioData) {
        // Read the next ID
        int idRead;
        ioData >> idRead;
        if (!ioData)
            break;

        // Check to see if the current record is the one being changed
        if (idRead == id) {
            // Seek the write position to the current read position
            ioData.seekp(ioData.tellg());
            // Output a space, then new number
            ioData << " " << newNumber;
            break;
        }

        // Read the current number to advance the stream
        std::string number;
        ioData >> number;
    }

    return true;
}

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

 

문자열 스트림 또한 stringstream 클래스를 통해 양방향 스트림이 가능합니다.

댓글