C++

I/O 완전 분석

Awesome Red Tomato 2022. 1. 1. 14:53

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

 

C++은 스트림(stream) 이라는 정교한 입출력 메커니즘을 제공한다. 스트림은 I/O를 유연하고 객체지향적으로 처리한다.

 

cout 스트림을 컨베이어 밸트에 비유할 수 있다. 스트림에 변수를 올려보내면 사용자 화면인 콘솔에 표시된다. 이를 일반화해서 모든 종류의 스트림을 컨베이어 밸트로 표현할 수 있다. 스트림마다 방향과 소스(출발지) 또는 목적지를 지정할 수 있다.

 

cout은 출력 스트림이다. 그래서 나가는(out) 방향을 갖는다. cout은 데이터 콘솔에 쓴다. 따라서 목적지는 '콘솔'이다. cout의 c는 콘솔(console)이 아니라 문자(character)을 의미한다. 즉, cout은 문자 기반 스트림이다. 반대로 사용자의 입력을 받는 cin이라는 스트림도 있다.

 

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

 

여기서 버퍼를 사용하는 스트림은 데이터를 버퍼에 저장했다가 블록 단위로 목적지에 보내고, 버퍼를 사용하지 않는 스트림은 데이터가 들어오자마자 목적지로 보낸다. 버퍼링을 하는 이유는 블록 단위로 묶어서 보내는 것이 효율적이기 때문이다. 버퍼를 깨끗이 비우는 flush() 메서드로 현재 버퍼에 담긴 데이터를 목적지로 보낸다.

 

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

 

1 스트림을 이용한 출력

더보기

출력 스트림은 <ostream> 헤더 파일에 저장되어 있다. 평소에 많이 쓰는 <iostream>  헤더에는 입력과 출력 스트림이 모두 저장돼 있다.

I/O stream

 

출력 스트림을 사용하는 가장 간편한 방법은 << 연산자를 이용하는 것이다. int, *, double 등 C++의 기본 타입은 모두 << 로 출력할 수 있다.

int i = 8;
cout << i << endl;

char ch = 'a';
cout << ch << endl;

string myString = "Hello World";
cout << myString << endl;
8
a
Hello World

 

cout 스트림은 C++에서 기본으로 제공하는 내장(기본) 스트림으로서, 콘솔(표준출력)에 값을 쓴다. 여러 조각으로 된 데이터를 하나로 합쳐 출력할 때도 << 연산자를 사용한다.

int j = 11;
cout << "The value of j is " << j << endl;
The value of j is 11

 

 

 

출력 스트림에서 가장 대표적인 연산자는 <<다. <ostream>은 << 연산자를 오버로딩한 몇 가지 유용한 public 메서드가 있다.

 

 

put(), write()

put()과 write()는 저수준 출력 메서드에 속하며, 문자 하나(put()) 또는 문자 배열 하나(write())를 인수로 받는다. 이 메서드는 가공을 거치지 않고 그대로 출력한다.

cout.put('a');

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

 

출력 스트림은 데이터를 바로 전송하지 않고 버퍼에 잠시 보관하는데, 스트림은 다음과 같은 조건을 만족할 때 그동안 쌓은 데이터를 모두 내보내고 버퍼를 비운다(flush).

 

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

 

 

good(), fail()

출력할 때 여러 에러가 발생할 수 있다. 존재하지 않는 파일을 열거나 메모리 부족으로 연산을 처리할 수 없을 때 출력 에러가 발생한다. good() 메서드는 스트림 상태 정보를 조회하여 스트림을 정상적으로 사용할 수 있는 상태인지 확인한다.

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

 하지만 이 메서드는 실패 원인을 구체적으로 알려주지 않는다. 이러한 정보는 bad() 메서드로 자세히 볼 수 있다. 또한 fail() 메서드를 사용하면 최근 수행한 연산에 오류가 있었는지 알 수 있다.

// flush() 연산 성공 유무 확인

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

 

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

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

 

스트림에 문제가 있으면 익셉션을 발생하도록 할 수 있다. ios_base::failure 익셉션을 처리하도록 catch 구문을 작성하면 된다.

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

 

 

1.2 스트림을 이용한 입력

입력 스트림으로부터 데이터를 읽는 두 가지 방법이 있다. 하나는 출력 연산자 <<로, 데이터를 출력하는 방법과 비슷하며 << 대신 입력 연산자 >>를 사용한다. 이때 >> 연산자로 입력 스트림에서 읽은 데이터를 변수에 저장할 수 있다.

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

>> 연산자는 기본적으로 공백을 기준으로 입력된 값을 토큰 단위로 나눈다. 위의 예제에서 "hello World"를 입력했다면 공백(스페이스) 이전의 문자(hello)만 담긴다. >> 연산자는 다양한 타입을 지원한다.

 

입력 스트림은 비정상적인 상황을 감지하는 여러 메서드를 제공한다. 대부분은 읽을 데이터가 없을 때 발생한다. 예를들어 파일 끝(eof, end-of-file)에 도달 했을 경우다. 출력 스트림과 마찬가지로 good(), bad(), fail() 메서드가 있다.

 

 

get()

입력스트림은 출력 스트림과 마찬가지로 >> 연산자보다 저수준으로 접근하는 메서드를 get()을 제공한다. get()의 가장 간단한 버전은 스트림의 다음 문자를 리턴한다. 주로 >> 연산자를 사용할 때 자동으로 토큰 단위로 잘리는 문제를 피하는 용도로 사용한다.

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

 

unget()

일반적으로 입력 스트림은 한 방향으로 진행하는 컨베이너 밸트와 같다. 그런데 unget() 메서드는 데이터를 다시 입력 소스 방향으로 보낼 수 있다.

 

unget()을 호출하면 스트림이 한 칸 앞으로 거슬러 올라간다. 그래서 이전에 읽은 문자를 스트림으로 되돌린다. unget() 성공 여부는 fail()로 확인한다.

void GetReservationData()
{
	string guestName;
	int partySize = 0;

	char ch;
	
	// noskipws: 스트림이 공백을 건너뛰지 말고 일반 문자처럼 취급하도록 설정.
	cin >> noskipws;
	while (cin >> ch)
	{
		// isdigit: 숫자를 판단하는 함수
		if (isdigit(ch))
		{
			cin.unget();

			if (cin.fail())
			{
				cout << "unget() failed" << endl;
			}
			break;
		}
		guestName += ch;
	}

	// 스트림이 에러 상태가 아니라면 partySize 값을 읽는다.
	if (cin)
	{
		cin >> partySize;
	}
	if (!cin)
	{
		cerr << "Error getting party size." << endl;
		return;
	}

	cout << "Thank you " << guestName << ", praty of " << partySize << endl;

	if (partySize > 10)
	{
		cout << "An extra gratuity will apply. " << endl;
	}
}

 

peek()

peek()은 '힐끗 본다'는 단어 그대로 get()을 호출할 때 리턴될 값을 미리 보여준다. 컨베이너 밸트에서 현재 처리할 지점에 있는 물건을 건드리지 않고 눈으로만 확인하는 것이다.

 

 

getline()

입력 스트림에서 데이터를 한 줄씩 읽을 때가 있는데 이를 위한 메서드이다. 이 메서드는 미리 설정한 버퍼가 가득 채워질 때 까지 문자 한 줄을 읽는다. 이때 한 줄의 끝을 나타내는 \0(EOL, end-of-line) 문자도 버퍼의 크기에 포함된다.

 

 

 

c++의 스트림은 단순 데이터만 전달하는 데 그치지 않고, 매니풀레이터(manipulator) 라는 객체를 받아 스트림의 동작을 변경할 수도 있다.

https://en.cppreference.com/w/cpp/io/manip

 

Input/output manipulators - cppreference.com

Manipulators are helper functions that make it possible to control input/output streams using operator<< or operator>>. The manipulators that are invoked without arguments (e.g. std::cout << std::boolalpha; or std::cin >> std::hex;) are implemented as func

en.cppreference.com

 

 

1.3 객체에 대한 입력과 출력

string은 C++의 기본 타입은 아니지만 << 연산자로 출력할 수 있다. C++에서는 <<, >>를 오버로딩하여 특정한 타입이나 클래스를 처리하게 만들 수 있다.

 

2. 스트링 스트림

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

 

string에 데이터를 쓸 때는 std::ostringstream 클래스를, 읽을 때는 std::istringstream 클래스를 사용한다. 스트링 스트림은 기본적으로 토큰화 기능을 제공하기에 텍스트 구문 분석(파싱 parsing) 작업에도 유용하다. ostringstream, istringstream은 각각 ostream과 istream을 상속하므로 기존 입출력 스트림처럼 다룰 수 있다.

 

 

 

3. 파일 스트림

파일은 스트림 개념과 정확히 일치한다. 파일을 읽고 쓸 때 항상 현재 위치를 추적하기 때문이다. std::ofstream, std::ifstream 클래스를 제공한다.

 

파일시스템을 다룰 때에는 에러 처리가 중요하다. 네트워크 저장소에 연결 중 끊길 수도 있고, 중간에 디스크가 가득 찰 수도 있기 때문이다.

 

파일 출력 스트림은 다른 출력 스트림과 비교해서 가장 큰 차이점은 생성자가 파일을 열 때 적용할 모드에 대한 인수를 받는다는 점이다. 출력 스트림의 디폴트 모드는 파일을 시작 지점부터 쓰는 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 플래그를 지정하면 파일을 바이너리 모드로 연다.

 

입력과 출력 모두 seek(), tell() 메서드를 가지고 있다. seek() 메서드는 입출력 스트림에서 현재 위치를 원하는 지점으로 옮긴다. 입력 스트림에 대해서는 seekg() g는 'get', 출력 스트림에 대해서는 seekp() p는 'put'을 쓴다. 

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

 

 

 

4. 양방향 I/O

지금까지의 스트림들은 입력 클래스 따로, 출력 클래스 따로 존재한다. 이와 달리 입력과 출력을 모두 처리하는 스트림이 있다. 

 

양방향 스트림은 iostream을 상속한다. 즉, istream과 ostream을 동시에 상속하기 때문에 다중 상속의 대표적인 예이기도 하다. 양방향 스트림은 <<과 >>을 동시에 지원한다.

 

fstream은 클래스 양방향 파일시스템을 표현한다. fstream은 파일 안에서 데이터를 교체할 때 유용하다.