[C++] 오버로딩( overloading )을 통한 사용자 정의 데이터 입출력

사용자 정의 데이터를 입출력 스트림과 주고받기

이 글에서는 C++의 입출력 클래스를 통해, 사용자 클래스의 데이터를 출력하고, 다시 입력받는 기능을 추가해 보겠습니다.

다음은 2차원 좌표 테이터를 담고 있는 사용자 정의 클래스입니다.

class Graph{
    
    using point = pair<double, double>;
    vector<point> coords;

public:
    const vector<point>& getCoords() const {
        return coords;
    }
}

 

이 클래스의 데이터를 화면에 출력하려면 다음과 같이 할 수 있을 것입니다.

int main(){

    Graph g;    // 이미 충분한 데이터를 가지고 있다고 가정
    auto& coords = g.getCoords();
     
    for( auto& x : coords){	// 범위 기반 for
        cout << x.first << " " << x.second << endl;
    }
}

그런데, 이 방식은 두 가지의 문제점을 가지고 있습니다.

첫 번째는 Graph 객체가 2개 이상일 경우에, 똑같은 for 구문을 객체 개수만큼 작성해야 한다는 것입니다.

그리고, 두 번째는 출력한 데이터를 다시 입력받을 때, 한 객체의 데이터가 정확히 어디에서 끝나는지 알 수 없다는 것입니다.

 

이 두 가지 문제를 해결한 것이 아래의 코드입니다.

class Graph{
    
    using point = pair<double, double>;
    vector<point> coords;

public:

    void print(){	// 데이터를 출력하기 위한 멤버 함수
        cout << coords.size() << endl;	// 좌표 개수 저장
        for( const auto& x : coords){
            cout << x.first << " " << x.second << endl;
        }
    }
}

이번 버전에서는 반복되는 for 구문을 멤버 함수로 변경하고, 이 객체가 가진 좌표 개수를 저장해서, 데이터의 크기를 알 수 있도록 변경했습니다.

 

하지만, 아직도 맘에 들지 않습니다.

C++에서는 스트림 삽입 연산자   <<   와 추출 연산자   >>  를 사용하는데, 이 객체를 출력하려면, 이 연산자들 사이에 위의 함수를 끼워 넣어야 합니다.

int main(){

    Graph g1, g2;

    cout << "start graph\n";
    g1.print();
    cout << endl;
    g2.print();
    cout << "end graph\n";
}

그리고, Graph 객체를 출력하려면, print 함수를 사용해야 한다는 것을 기억해야 합니다.

 

따라서, 이런 경우 가장 좋은 해결책은 Graph 객체에 대한 삽입 연산자   <<   오버로딩( overloading )하는 것입니다.

오버로딩은 같은 함수 식별자에 대해, 다양한 타입의 매개변수를 지정할 수 있게 하는 C++의 기능입니다.

 

삽입 연산자 <<

위의 코드를 오버로딩한 코드는 다음과 같습니다.

이때의    <<    연산자는 이항 연산자( binary operator )로서, 왼쪽 피연산자는 ostream 객체가 되고, 오른쪽 피연산자는 Graph 객체가 됩니다.

class Graph{
    
    using point = pair<double, double>;
    vector<point> coords;

public:

    // private 멤버 변수에 접근할 수 있도록 friend로 선언
    friend ostream& operator<<( ostream& out, const Graph& g);
};

// operator <<를 오버로딩
ostream& operator<<( ostream& out, const Graph& g){

    out << g.coords.size() << endl;

    for( const auto& x : g.coords){
        out << x.first << " " << x.second << endl;
    }

    return out;	// 연쇄 호출을 위해 
}

std::ostream은 C++의 출력 기능을 담당하는 최상위 클래스입니다.

그리고, 이 클래스의 전역 객체가 std::cout입니다.

따라서, 위와 같이 선언하는 것이 범용적입니다.

 

다음 이미지에서 C++ 입출력 클래스 관계도를 볼 수 있습니다.

C++의 표준 입출력 상속 관계 ( 출처: cplusplus.com )

 

그리고, 이 버전에서는 Graph 객체의 private 멤버 변수에 접근할 수 있도록, Graph 선언부에 friend 함수를 선언했습니다.

만약, 직접 접근할 필요가 없다면 반드시 friend로 선언할 필요는 없습니다.

 

friend 키워드에 관한 내용은 여기에서 볼 수 있습니다.

 

[C++] friend 키워드 사용법 ( friend 함수, 클래스 )

friend 키워드friend 키워드는 클래스의 멤버가 아닌 함수나 다른 클래스에게, 해당 클래스의 모든 멤버에 접근할 수 있는 권한을 부여하기 위하여 쓰입니다.이 키워드는 권한을 주는 클래스 내에

codingembers.tistory.com

 

또한, 이 연산자는 매개변수로 주어진 out 객체의 참조값을 그대로 반환해야 합니다.

이것은, 입출력 연산의 함수 호출이 연속적으로 이루어지도록 하기 위한 것입니다.

만약, 이 함수가 다음과 같이 void를 반환한다고 가정해 보죠.

// void를 반환하는 경우
void operator<<( ostream& out, const Graph& g){

    out << g.coords.size() << endl;

    for( const auto& x : g.coords){
        out << x.first << " " << x.second << endl;
    }
}

Graph g1, g2;
cout << g1 << g2;

그러면, 위의 호출은 다음과 같은 순서로 호출이 됩니다.

( cout << g1 ) << g2;

그리고,   ( cout << g1 )   은  void를 반환하므로,   void << g2   가 되어서 오류 코드가 됩니다.

따라서, 연속으로 객체를 출력하고자 한다면, ostream 객체를 반환해야 합니다.

 

그리고, 이러한 연산자의 반환 타입은 ostream 객체의 참조입니다.

이것이 참조 타입일 수밖에 없는 이유는, 입출력 스트림의 복사 기능을 정확하게 정의할 수 없기 때문입니다.

그래서, C++ 표준라이브러리의 입출력 객체들은 복사가 불가능합니다.

좀 더 자세히 말한다면, ostream와 istream 클래스의 복사 생성자와 복사 대입 연산자를 삭제했기 때문에( = delete 구문사용 ), 이러한 객체는 값으로 전달( call by value )을 할 수 없습니다.

( "초기화( initialization )의 종류 정리" 글 참조 )

 

이젠, 이러한 코드가 가능합니다.

int main(){

    Graph g1, g2;
    cout << "start graph\n" << g1 << endl << g2 << "end graph\n";
}

 

추출 연산자 >>

입력을 받는 기능은 다음과 같이 구현할 수 있습니다.

이전 항목과 마찬가지로,    >>    연산자를 오버로드하고, Graph 클래스의 멤버에 접근하기 위해 friend 선언을 추가했습니다.

그리고, 입력 기능을 담당하는 istream 객체 참조를 매개변수로 받아 사용 후, 다시 반환합니다.

class Graph{
    
    using point = pair<double, double>;
    vector<point> coords;

public:

    // 표준 입력으로부터 데이터 추가
    friend istream& operator>>( istream& in, Graph& g); 
    friend ostream& operator<<( ostream& out, const Graph& g);
};

istream& operator>>( istream& in, Graph& g){

    g.coords.clear();   // 이전 데이터 삭제
    
    size_t sz = 0;	// 좌표의 개수
    in >> sz;

    g.coords.reserve( sz ); // 빈번한 재할당을 막기 위해

    double x, y;
    for( auto i = 0; i < sz; i++){
        in >> x >> y;
        if ( !in )  // 잘못된 데이터가 들어있는 경우
            break;
        
        g.coords.emplace_back( x, y );
    }    
    
    return in;
}

위에서, 벡터의 reserve함수를 사용하면, 미리 필요한 메모리를 할당할 수 있습니다.

벡터는 내부의 버퍼가 가득 차면, 원소를 추가로 저장하기 위해 자동으로 메모리를 할당하는데, 이 기능의 비용이 상당히 비싼 편입니다.

따라서, 위의 좌표 개수와 같은 데이터로 필요한 메모리양을 계산할 수 있다면, 성능을 향상시킬 수 있습니다.

 

그리고, 위의 if 구문은 괄호 안의 표현식의 값을 bool로 평가하기 때문에, 컴파일러는 istream 객체를 bool로 암시적인 변환을 시도합니다.

if ( !in )	// 잘못된 데이터가 들어있는 경우
    break;

다행히, 이 객체는 bool로 전환하는 연산자를 지원하므로, 위의 코드가 의미가 있게 된 것입니다.

이 연산자는 istream 객체가 스트림의 데이터를 추출 시의 결과를 저장하고 있는 상태 정보를 이용해서, true 또는 false를 반환합니다.

 

마지막으로, emplace_back은 전달받은 인자로부터 포인트 객체를 내부적으로 생성해서 추가하는 함수입니다.

이 함수는 포인트 객체( pair <double, double> )가 값으로 전달되는 경우 발생하는 복사 비용을 절약합니다.

 

emplace 함수에 관한 내용은 여기에서 볼 수 있습니다.

 

[C++] STL emplace 함수 설명 및 사용법

emplace 함수std::emplace 함수는 STL 컨테이너( vector, list, set, map, deque 등 )에서 사용가능한, 새로운 원소를 삽입하는 함수입니다. 이와 비슷한 함수로  vector의 emplace_back, list의 emplace_front, emplace_back, m

codingembers.tistory.com

 

파일 입출력

이전 항목에서 작성한 연산자들은, 입출력 최상위 클래스( iostream )와 같은 인터페이스를 구현하고 있는 파생 클래스라면, 이들과 같은 방식으로 데이터를 주고받을 수 있습니다.

위 클래스 상속 관계 이미지의 fstream과 stringstream가 그러한 클래스들입니다.

 

fstream은 파일 스트림으로 데이터를 입출력하는 기능을 갖고 있고, stringstream은 std::string 객체와 데이터를 입출력하기 위해 만들어진 클래스입니다.

 

여기서는 ofstream 객체를 통해 사용자 클래스의 데이터를 저장하는 기능을 구현합니다.

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

int main(){

    Graph g;    
    cin >> g;	// 키보드로부터 데이터 입력

    string name("graph.txt");	// 출력 파일명
    ofstream file(name);
    
    if (!file){
        std::cerr << name << "cannot be created\n";
        exit(1);
    }

    file << g;	// 사용자 데이터 출력
}

▲입력

5
3 4
1 1
1 -1
2 2
3 3

위의 입력 데이터를 표준 입력( 키보드 )으로부터 입력받는 기능은 이전 항목에서 이미 구현했었습니다.

이 데이터를 ofstream 객체를 통해 내보내면, 모니터가 아니라 파일에 데이터가 출력됩니다.

 

ofstream 객체는 생성 시, 파일명과 파일을 여는 방식( 기본값은 쓰기 ios_base::out )을 입력받아서 파일을 엽니다.

만약, 파일 열 수 없다면, 이전 설명과 마찬가지로, file 표현식은 false로 변환됩니다.

 

제대로 파일을 열었다면, 이제 표준 입력과 같은 방식으로 데이터를 출력할 수 있습니다. 

그리고, ofstream은 파괴 시, 자동으로 파일을 닫습니다.

 

이 과정에서, 이전 항목에서 작성한 출력 연산자를 조금도 수정할 필요가 없었습니다.

인터페이스를 세심하게 정하고, 이를 제대로 지키는 것이 얼마나 중요한 일인가를 제대로 보여주는 예라고 할 수 있을 것입니다.

 

다음은 위에서 저장한 파일을 다시 읽는 코드입니다.

int main(){
    
    Graph g; 
    string name("graph.txt");	// 이전에 작성된 파일
    
    ifstream file(name);	// 입력 파일 스트림
    if (!file){
        std::cerr << name << "cannot be read\n";
        exit(1);
    }

    file >> g;  // 파일로부터 입력

    cout << g;  // 화면에 출력
}

▼출력

5
3 4
1 1
1 -1
2 2
3 3

 

 

 

  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유