cout 과 endl을 만들어보며, 원리를 배워보자 🥸

안녕하세요! 두두코딩 널두 🥸 입니다 ✋
오늘은 cout과 endl의 원리에 대해 알아보겠습니다.

🖇 소스코드에 마우스를 올리고 copy 버튼을 누를 경우 더 쉽게 복사할 수 있습니다!

궁금한 점, 보안점 남겨주시면 성실히 답변하겠습니다. 😁
+ 감상평 댓글로 남겨주시면 힘이됩니다. 🙇

Intro.

이번 포스팅에서는 cout 동작 원리 / endl 동작원리 에 대해 알아보겠습니다.

cout 동작 원리

우리는 cout 객체를 화면에 출력하기 위해 사용한다.

cout을 활용해 출력하기 위해서는 << 를 활용하는데, 해당 연산자 같은 경우 내부적으로 버전별로 재정의 되어져있다.

namespace std
{
  class ostream
  {
    //..
    // 버전 별로 존재함.
    operator<<(int);
    operator<<(double);
    ...
  }

  ostream cout; // cout이라는 객체로 제공함.
}

위의 코드와 같이 ostream 객체 내 cout 이라는 객체로 제공하고, 내부적으로는 연산자 재정의를 활용하고 있다. ostream 관련해서는 뒷 부분에서 조금 더 자세하게 알아본다.

정리해보자면, cout은 객체로 ostream의 클래스를 객체화 한 것이다. 내부적으로 operator를 재정의해서 사용하고 있으며 primimitve type 같은 경우 타입별로 재정의해서 사용하고 있다.

연산자 재정의를 확인해보고자 한다면, 아래와 같이 << 이 아닌 operator<<()를 활용해도 동일한 결과 값을 출력하는 것을 확인 할 수 있다.

int main()
{
  int n = 10;
  int d = 3.4;

  cout << n; // 10
  cout << d; // 3.4

  cout.operator<<(n); // 10
  cout.operator<<(d); // 3.4
}

cout 구현

아래의 코드가 동작하도록 직접 만들어보자.

int main()
{
  cout << 3;
}

우리는 출력을 간편하게 하기 위해 <cstdio> 헤더파일 내 printf()를 활용한다. 보통 cout과 같은 출력 함수들에서 호출하는 함수는 시스템에서 출력하는 함수를 호출한다. 이를 테면 linux 환경 같은경우 write() 시스템 콜을 활용해 직접 monitor 등에 쓰는동작으로 화면에 표기한다.

거기 까지 가기에는 너무 난이도가 있으니, printf()를 시스템 출력함숙로 간주하고 사용하고자한다.

cout을 통해 3을 출력하기 위해서는 아래와 같은 동작이 필요하다.

1. ostream 클래스를 만든다
2. ostream 클래스 내 필요한 타입의 «연산자를 재정의한다
3. 재정의한 동작 내 printf() 를 활용하여 화면에 입력받은 값을 출력하도록 한다.
4. ostream 클래스를 cout 객체로 만들어 사용자가 접근할 수 있도록 한다

위와 같은 동작을 기반으로 아래의 함수를 구현해 볼 수 있다.

#include<cstdio>

class ostream
{
public:
  void operator<<(int n) {
    printf("%d", n);
    return;
  }
};

ostream cout;

위와 같이 만들어 위 int main() 코드를 합쳐 빌드할 경우 3이 출력되는 것을 확인할 수 있다.

만약 아래의 코드라면 어떻게 수행될까?

int main()
{
  // 이 코드 수행시 에러남.
  cout << 3 << 4;
}

위 코드 수행할 경우 에러가 발생한다. 현재 연쇄적으로 << 연산자를 수행할 수 없기 떄문이다. 연쇄적으로 값을 수행하기 위해서는 return value를 참조타입으로 반환해야된다. 이전 포스팅을 통해 다뤘으니 참고하도록 하자.

조금 수정해서 << 연쇄적 동작을 하도록 만들어보자.

#include <cstdio>
class ostream
{
public:
  ostream& operator<<(int n) {
    printf("%d", n);
    return *this;
  }
};

ostream cout;

this 포인터를 참조로 반환함으로 임시객체가 아닌 실 객체를 반환하도록 한다. 위와 같이 작성할 경우 문제 없이 연쇄적 동작도 잘 수행된다. 여기서 namespacestd로 씌워주면 실제 우리가 사용하는 것과 같이 std::cout 형태가 된다.

#include <cstdio>
namespace std {
  class ostream
  {
  public:
    ostream& operator<<(int n) {
      printf("%d", n);
      return *this;
    }
  };
  ostream cout;
}


int main()
{
  std::cout << 3 << 4;
}

연산자 재정의를 할 수 있다면 우리도 cout을 쉽게 만들 수 있다.

basic_ostream

98년도 이전에는 ostream이라는 객체를 사용했다.

98년 이후 basic_ostream이라는 개념이 등장했으며, 내부적으로 ostreamwostream을 객체로 만들어 사용하고 있다.

98년 이후 ostream에서 basic_ostream으로 개편된 이유는 unicode 의 상용화 때문이다. 유니코드 같은 경우 2byte로 나타내는 문자열인데, 해당 값을 통해 한글을 비롯해 다양한 문자를 표현할 수 있다.

기존에는 영어기반 1byte문자로 (ascii 코드) 표현이 가능했지만, 다양한 문자를 받고자 유니코드를 도입했다. 따라서 기존과 다른 객체양식이 필요했고, wostream이라는 개념이 등장했다.

wcout, wstring과 같은 내용을 종종 볼 수 있는데, 해당 객체는 유니코드를 다룰 수 있는 객체구나 정도로 이해하면된다.

endl

endl 같은 경우 객체가 아닌 함수라는 점을 기억하자.

#include <iostream>

using namespace std;

int main()
{
  cout << endl;

  endl(cout); // 위 동작을 아래와 같이 표현할 수 있다.
}

잉.. endl() 동작이 어떻게 되는거지?

같이 만들어보며, 이해해보자.

endl 구현

기존에 만들었던 cout 객체를 활용해 endl을 구현해 동작시켜 보도록 하자.

#include <cstdio>
namespace std {
  class ostream
  {
  public:
    ostream& operator<<(int n) {
      printf("%d", n);
      return *this;
    }
    ostream& operator<<(char c) {
      printf("%c", c);
      return *this;
    }
  };
  ostream cout;
}

int main()
{
  // 해당 동작이 가능하게 만들어보자.
  std::cout << 3 << std::endl;
}

endl 을 만들기 위한 로직

1. operator«() 재정의를 통해 endl을 받도록한다 2. endl() 내부에선 개행처리를 하도록 한다

굉장히 심플하다.

#include <cstdio>

namespace std {
  class ostream {
    //...

    // 연산자 재정의 필요.
    // << 을 통해 전달함.
    ostream& operator(ostream&(*f)(ostream&))
    {
      f(*this); // endl(cout); 이런 방식으로 전달하면 됨.
      return *this;
    }
  }
}

우선 연산자 재정의 구현 부분 부터 보도록 하자.

ostream& 참조로 받는 이유는 연쇄적 동작을 위해서이다. 인자로 ostream&(*f)(ostream&) 은 낯설 수 있지만 함수의 포인터 형태이다. 함수를 전달받아 해당 함수에 cout을 전달해주는 방식으로 endl은 동작한다.

그렇다면 endl 함수 내부는 어떻게 되어있을까?

// 형태가 중요
// ostream&(*f)(ostream&)

ostream& endl (ostream& os)
{
  // 전달받은 ostream에 연산자 재정의를 활용해 개행을 출력하도록 한다.
  // 즉, cout << '\n'; 이 모양이 됨.
  os << '\n';
  // 연쇄작용 때문.
  return os;
}

위와 같이 함수형식으로 구현하면 된다.

여기서 중요한 점은 전달받은 ostream을 활용해 연산자 재정의를 다시 호출한다는 점이다.

그렇다면 여기서 생각해볼 수 있다.

왜 이렇게 어렵게 간다는 말인가?

그냥 \n만 출력해주면 되는거 아니야? 라고 생각할 수 있다. 이렇게 만든이유는 사용자가 직접정의 할 수 있도록 유연성을 부여하기 위함이다.

예를 들어 아래와 같은 동작을 해야된다면 어떻게 해야될까?

// tab을 구현해보자.
// tab은 사이를 tab 간격 만큼 띄우는 역할을 한다.
std::cout << 'A' << tab << 'B' << endl;

tab 구현은 굉장히 간단하다.

// 형태만 맞춰주면 됨!

ostream& tab(ostream& os)
{
  os << '\t';
  return os;
}

위와 같이 구현해 만들어주면, cout에서 바로 tab이라는 키워드 형식의 함수를 사용할 수 있다.

우리가 흔히 사용한 IO manipulator 함수들도 다 이런형식으로 구현되어져 있다. (e.g. setw(10), hex 등)

아래처럼 기존의 iostreamcout을 활용하여 재밌게 구현해 볼 수 도 있다.

menu라는 출력함수를 만들어보자.

#include <iostream>

using namespace std;

ostream& menu(ostream& os)
{
  os << "1. 짜장면\n" << "2. 탕수육\n";
  return os;
}

int main()
{
  cout << menu << endl;
}

위 코드를 수행하보면 아주 재밌는 결과를 만날 수 있을 것이다.

사용자 정의 타입 연산자 재정의

아래의 코드를 보면, Complex의 값을 출력하기 위해 print()를 사용한다는 것을 볼 수 있다.

이를 좀 더 간편하게 하기 위해 cout을 활용할 수 없을까?

#include <iostream>

using namespace std;

class Complex
{
  int re, im;
public:
  Complex(int r = 0, int i = 0) : re(r), im(i) {}

  void print() const
  {
    cout << re << ", " << im << endl;
  }
};

int main()
{
  Complex c(1,1);

  cout << c; // 이렇게는 할 수 없을까?
}

operator<<() 재정의를 하면 cout를 활용할 수 있다. 우리가 앞서 재정의 한 방식은 cout을 활용해 멤버함수로 구현했는데 지금은 std::cout에 접근을 할 수 없다.

이 경우는 일반함수 연산자 재정의 방법을 활용하면 된다. 아래의 코드는 일반함수를 활용해 재정의 한 코드이다.

#include <iostream>

using namespace std;

class Complex
{
  int re, im;
public:
  Complex(int r = 0, int i = 0) : re(r), im(i) {}
  // 멤버 접근을 위해!!
  friend ostream& operator<<(ostream&, const Complex&);
};

ostream& operator<<(ostream& os, const Complex& c)
{
  os << c.re << ", " << c.im;

  // 연쇄적 동작을 위해..
  // 멤버는 *this를 보내주지만, 일반함수 같은 경우 받은걸로 돌려주면 됨.
  return os;
}

int main()
{
  Complex c(1,1);

  cout << c; // 프린트 활용안하고 바로 출력가능!
}

위 코드는 일반 함수 재정의 코드이다. 여기서 주의할 점은 내부 데이터 접근이 불가능하기 때문에 friend 키워드를 사용했단점을 기억하자!

Outtro.

해당 포스팅은 Ecourse의 C++ Basic 강의를 참고해 작성되었습니다.

강의를 참고하실 분은 여기를 클릭해 확인해주세요!