[C++ Primer Plus] 15. Friends, Exceptions, and More
Coding/Unreal, C++

[C++ Primer Plus] 15. Friends, Exceptions, and More

대답해야 할 질문들

  • Friend class와 Friend member function의 사용법이 무엇인가?
  • Nested class를 쓰는 이유는 무엇인가?
  • return과 throw의 차이점이 무엇인가?
  • exception을 레퍼런스로 catch하는 이유는 무엇인가?
  • undefined exception과 unexpected exception이 무엇인지 설명하고 어떻게 해결할 수 있는지 설명하시오.
  • RTTI의 세가지 구성요소는 무엇인가?
  • C++의 explicit casting을 설명하시오.

Friends

  • 이제까지 friend function만 배웠지만, friend class도 가능하다.
  • friend class가 마치 OOP를 해칠 것만 같은 느낌을 주지만 그렇지 않다. 그 이유는 2개다. 먼저 friend 관계는 외부에 노출되지 않는다. 두번째로 friend라고 해도 모든 friend 클래스에게 전적으로 멤버를 공개하는 것이나 아니라 friend 클래스의 특정 함수에게만 공개할 수도 있다. 즉, friend 클래스도 결국은 interface로서 작동할 수 있다.

Friend Classes

  • TV와 리모컨 클래스가 있다고 생각해보자. 리모컨을 통해 TV를 동작시킬테니, 두 클래스 사이에 어떤 관계가 있긴할 것이다. 그런데 대체 무슨 관계라고 할 수 있을까? Is-A, Has-A 관계에 해당되지 않는다. 이 때 사용할 수 있는 것이 friend class다.
  • 아래 예제는 TV 클래스에서 리모컨 클래스를 Friend로 지칭해서 리모컨 클래스가 TV 클래스의 protected, provate 멤버에 접근할 수 있도록 해주는 코드다.
class Tv
{
public:
	friend class Remote;
    ...
}

Friend Member Functions

  • 그런데 직접 리모컨 클래스를 구현해보니, 왠만한 경우에 Tv 클래스의 public 멤버만 쓴다는 것을 깨달았다. 실제로 Tv 클래스의 Private 멤버를 쓰는 일은 리모컨의 set_channel()에서만 일어났다. 이 경우에 Friend Member Functions을 지시할 수 있다.
class Tv
{
public:
	friend void Remote::set_channel(Tv & t, int c);
    ...
}
  • 이 때, 실제로 코드를 써보면 에러를 맞닥뜨리게 된다. Tv 클래스와 Remote 클래스의 정의 순서 때문이다.
// 1. TV class가 friend member function에서
// Remote를 참조하기 때문에 Remote가 클래스임을 컴파일러에게 알려줘야 한다.
// 2. Remote class도 멤버 함수에서 TV 클래스를 쓰기 때문에 TV보다 아래에 있어야 한다.

class TV
{
...
}

class Remote
{
...
}
  • 이를 타파하기 위해 forward declaration을 헤더에서도 써야한다.
class Tv;

class Remote
{
...
}

class Tv
{
...
}

Nested Classes

  • C++에서는 클래스 정의부 안에서 다른 클래스를 또 정의할 수 있다. 이 때, 그 안에 있는 클래스를 Nested Class라고 부른다.
  • Nested Class는 Containment와 다르다. Containment는 어떤 클래스의 오브젝트를 멤버로 갖는 것이고 Nested Class는 아예 클래스 정의 자체를 안에서 하는 것이다. 애초에 멤버라고는 할 수 없다.
  • 그래서 Nested Class를 바깥에서 접근하려면 품고 있는 클래스가 정의를 public section에서 해줘야 한다.
  • Nested Class를 쓰는 이유는 주로 이름 충돌을 피하기 위해서다.

Nesting in a Template

  • Template Class에서 Nested Class를 정의할 때, 뭔가 어려움이 생길까? 답은 '아니오'다. 실제로 Template에 들어온 type parameter를 Nested Class의 정의부에 사용할 수도 있다.
template <class Item>
class QueueTP
{
private:
	class Node
    {
    public:
    	Item item; // Nesting class의 type parameter 사용
    ...
    }
...
}

Exceptions

  • 원하지 않는 값이 들어오거나 메모리 할당에 실패하는 등 에러 상황이 발생했을 때, 프로그래머의 대처는 3가지다.
    1. exit() 또는 abort()를 사용해서 프로그램을 꺼버린다.
    2. return을 할 때, -1과 같은 에러 숫자를 리턴해서 함수 바깥에서 처리한다.
    3. exception을 throw한다.

Exception Mechanism

  • Exception은 총 3가지 부분으로 실행된다.
    1. exception을 throw한다.
    2. exception을 catch한다.
    3. try 블럭을 설정한다.
  • throw 구문은 jump라고 이해하면 쉽다. 실행해야 하는 명령줄을 try 블럭 끝 줄로 쭉 이동시킨다.
  • catch 블록은 throw된 exception과 exception handler를 받아들여
  • try 블록은 exception이 발생할 수 있는 구문을 묶어준다.

Using Objects as Exceptions

  • exception 시스템의 좋은 점 중 하나는 다양한 exception type을 throw할 수 있어서 예외에 따라 다른 대처가 가능하다는 점이다.
class bad_hmean
{
private:
	double v1;
    double v2;
public:
	void mesg();
}

...

if (a == -b)
	throw bad_hmean();

Exception Specifications Meet C++11

  • 실제로 꽤 쓸만해보이는 원칙이 현장에선 먹히지 않는 법이다. C++98의 exception specification이 그 예다.
  • exception specification은 C++98에 도입됐고 C++11에서 deprecated된 기능이다. 문법은 아래와 같다.
double harm(double a) throw(bad_thing); // bad_thing 에러를 throw한다.
double marm(double) throw(); // 어떤 에러도 throw하지 않는다.
  • 위 구문은 두 가지 역할을 한다.
    • 프로그래머에게 해당 함수가 어떤 예외를 던질 수 있는지 명시한다. → 그냥 주석으로 하면 된다.
    • 컴파일러에게 정말로 이 약속을 지켰는지 검사할 수 있게 한다. → 때때로 변하는 코드 때문에 사실 큰 의미가 없다.
  • 결국 C++11에서 exception specification은 사라졌고 이를 대신할 새로운 키워드가 생겼다. 바로 noexcept다. noexcept는 throw()와 동일한 의미로 해당 함수가 던지는 exception이 없다는 의미다. 이 키워드는 문맥적인 의미뿐만이 아니라 컴파일러가 더 효율적으로 컴파일할 수 있도록 도와준다. (try 구문에서 최적화하는 듯?)
double marm() noexcept;

Unwinding the Stack

  • throw 구문을 jump라고 이해하라고 위에서 말했다. 하지만 이게 어떻게 가능할까? 이제까지 함수의 실행순서에 관여하는 것은 return 뿐이었다. 함수는 호출되면 파라미터를 포함해 지역변수들을 모두 Stack에 생성한다. 그 후, return이 되면 Stack에 있던 메모리를 return adress까지 모두 해제한다. 그렇다면 throw는?
  • throw가 되면 return과는 달리 return address가 아니라 try의 return address까지 모든 메모리를 해제한다. 이를 Unwinding the Stack이라고 한다. 그래서 Stack에 할당된 메모리들이 안전하게 해제될 수 있는 것이다.

출처: C++ Primer Plus 6th

More Exception Features

  • throw가 return과는 다른 점이 있는데, throw는 항상 exception 오브젝트의 복사본을 throw한다는 것이다.
  • 그렇게 되면 왜 굳이 아래와 같은 문법을 쓰는지 이해가 안될 수 있다.
try {
	super();
}
catch(problem &p) { // 왜 굳이 레퍼런스로 받아낼까? 어차피 복사해서 주는데?
	...
}
  • 그 이유는 상속 때문이다. 던지는 exception 오브젝트는 높은 확률로 derived-class일 것이다. 이 때, 부모 클래스의 레퍼런스로 받으면 그 자식들까지 전부 받아낼 수 있다.
  • 또한 만약 모든 exception을 일단 받아내고 싶으면 다음과 같은 문법도 (의외로) 가능하다.
catch (...) { //statement }

The exception class

  • C++의 exception 기능은 exception을 언어 자체에서 지원하기 위해서다. 그냥 이렇게만 지원하면 각 프로그램들은 exception을 규격화하는 일을 해야한다. std::exception은 그 규격을 정해주는 역할을 한다.
  • std::exception 클래스를 쓰고 싶으면 <exception> 헤더를 include하고 다음과 같이 예외 클래스를 정의하면 된다.
class bad_gmean : std::exception
{
public:
	const char * what() { return "bad arguments to gmean()"; }
}


...

try {
...
}
catch(std::exception& e) {
	std::cerr << e.what() << std::endl;
}
  • stdexcept는 std::exception 클래스보다 좀더 상세하게 예외를 정의한다. stdexcept는 예외 클래스를 크게 logic_error와 runtime_error 클래스로 나누고 자식 클래스를 두고 있다. 관계는 다음과 같다.
    • logic_error family
      • domain_error
      • invalid_error
      • length_error
      • out_of_bounds
    • runtime_error
      • range_error
      • overflow_error
      • underflow_error

The bad_alloc Exception and new

  • new 연산을 통해 메모리를 할당하려는데, 여기에 실패하면 bad_alloc exception을 던진다. 하지만 아래 구문을 사용하면 에러를 안던지게끔 할 수 있다.
Big * pb;

pb = new (std::nothrow) Big[10000];
if (pb == 0)
{
	cout << "Could not allocate memory. Bye.\n";
    exit(EXIT_FAILURE);
}
  • new에 실패했기 때문에 pb 값이 0으로 된 것을 주목하자.

When Exceptions Go Astray

  • Exception을 잡는데 실패하는 경우는 2가지다.
    1. catch 구문에 걸리지 않는 type의 예외 → 해당 exception은 unexpected exception로 branded 된다.
    2. try를 하지 않았는데, throw를 하는 경우 → 해당 exception은 undefined exception로 branded 된다.
  • 기본적으로 두 예외가 발생해버리면 abort()가 된다. 정확히 말하자면 abort() 함수를 바로 호출하는게 아니라 terminate() 함수를 부르고 이 함수가 기본적으로 내부에서 abort()를 호출한다. 이러한 방식을 바꾸기 위해서 다음 함수들이 <exception>헤더에 정의되어 있다.
typedef void (*terminate_handler)();
terminate_handler set_terminate(terminate_handler f) noexcept;
void terminate() noexcept;
  • 해당 함수들을 override하면 바로 abort하는 디폴트 세팅을 바꿀 수 있다.
void myQuit()
{
	cout << "Terminating due to uncaught exception\n";
}

set_terminate(myQuit);

Exception Cautions

  • exception은 바로 프로그램을 끄거나 에러값을 리턴하는 것보다 좋아보이지만 trade-off가 있다.
    • 먼저 프로그램의 사이즈를 크게 만들고 속도를 늦춘다.
    • template이나 동적 메모리 할당에 대응하기 힘들다.
    • 바로 꺼버리는 경우가 디버깅 등을 위해 좋을 수도 있다.

Runtime Type Identification (RTTI)

  • RTTI의 목적은 객체의 타입을 런타임 중에 추측할 수 있는 표준 방법을 제시하는 것이다.
  • RTTI가 필요한 경우가 언제일까?
    • class 타입에 따른 메소드를 호출하기 위해서 → virtual function으로 풀 수 있다면 RTTI를 안쓰는게 낫다.
    • debugging을 목적으로 객체의 타입을 알아내기 위해서 이건 RTTI 밖에 답이 없다.

How Does RTTI Work?

  • RTTI는 세가지 구성요소로 작동된다.
    1. dynamic_cast 연산자
    2. typeid 연산자
    3. type_info 구조체

The dynamic_cast operator

  • dynamic_cast 연산자가 RTTI 컴포넌트 중에서 가장 많이 쓰인다. 이 연산자는 해당 객체가 어떤 타입인지 알려주지 않는다. 그보다는 특정 포인터가 해당 객체를 안전하게 가리킬 수 있는지 검사한다. 만약 그럴 수 없다면 0을 준다.
Superb * pm = dynamic_cast<Superb *>(pg);

The typeid operator and type_info Class

  • typeid 연산자는 클래스의 이름이나 오브젝트를 받아서 type_info 오브젝트의 레퍼런스를 반환한다.
  • type_info 클래스는 name() 메소드가 있는데, 이를 이용해서 디버깅을 할 수 있다.

Type Cast Operators

  • Bjarne Stroustrup은 C type cast operator가 너무 느슨하다고 말한다.
struct Data {...};
struct Junk {...};

Data d = {2.5e33, 3.5e-19, 20.2e32};
// C에서는 아래 코드의 말도 안되는 캐스팅이 전부 바로 가능하다.
char* pch = (char *) (&d);
char ch = char (&d);
Junk * pj = (Junk *) (&d);
  • 그래서 C++은 좀더 규칙을 가진 캐스팅 방식을 지원한다. 이는 4가지 연산자를 통해 구현됐다.
dynamic_cast
const_cast
static_cast
reinterpret_cast

const_cast

  • const_cast는 해당 type에 const를 없애거나 추가할 때 사용한다.
High bar;
const High * pbar = &bar;

High * pb = const_cast<High *>(pbar);
  • 이런 기능은 const를 잠시 떼놓아야 할 때 사용된다. const_cast는 기존 캐스팅을 통해서도 가능한데, 기존 캐스팅은 아예 type까지 바꿀 수 있다.
High * pb = (High *)(pbar);
Low * pl = (Low *)(pbar); // 다운 캐스팅과 컨스트 캐스팅을 동시에 할 수 있다.
  • const_cast만 보면 const 특성을 아예 지워버리는 것 같은데, 실제로는 그렇지 않다. 다음 예제를 보자.
int main()
{
	int pop1 = 38383;
    const int pop2 = 2000;
    
    change(&pop1, -103); // pop1은 캐스팅된 const이기 때문에 값이 바뀐다.
    change(&pop2, -103); // 이 때, pop2는 진짜 const이기 때문에 값이 바뀌지 않는다.
    
    cout << pop1 << " " << pop2 << endl;
}

void change(const int * ptr, int n)
{
	int * pc;
    
    pc = const_cast<int *>(ptr);
    *pc += n;
}
  • 즉, 변수의 const 특성 자체를 없애는 것이 아니라 임시로 const가 붙었을 때, 이를 없애주는 역할인 것이다.

static_cast

  • static_cast는 계층 관계에 있는 클래스 간 형변환이 가능하다. 즉, High -> Low 또는 Low ->High 모두 가능하다. 하지만 Low -> Pond 는 계층관계가 아니므로 안된다.

reinterpret_cast

  • reinterpret_cast는 계층 관계를 벗어나서 자유롭게-위험하게 캐스팅이 가능하다. Low -> Pond도 reinterpret_cast라면 가능하다.
  • 다만 완전히 자유로운 것은 아니다. 예를 들어, pointer 타입을 floating-type이나 short 타입으로 형변환할 수 없다. 모든 정보를 담을 수 없기 때문이다.
  • reinterpret_cast는 주로 로우 레벨에서 사용된다.

References

  • C++ Primer Plus 6th, Chapter 15