레이블이 상속인 게시물을 표시합니다. 모든 게시물 표시
레이블이 상속인 게시물을 표시합니다. 모든 게시물 표시

2015. 2. 5.

[C++]상속과 함수 오버라이딩(Function Overriding)

 상속에 연관되어 살펴볼 만한 간단한 주제 중 하나가 함수 오버라이딩이다. 함수 오버라이딩은 앞으로 만나볼 C++의 다형성(polymorphism)과 가상 함수(virtual)의 출발점과도 같다. 그리고 이참에 함수 오버로딩(function overloading)과 확실히 구분하자.


함수 오버라이딩(function overriding)

 함수 오버라이딩이란 기초 클래스와 동일한 이름의 함수를 유도 클래스에서 정의하는 방식으로 구현이 된다.
 First 클래스에서 정의한 MyFunc() 함수를 자식 클래스 Second에서 다시 정의하고 있다. Second 클래스의 자식 클래스인 Third 클래스에서도 MyFunc() 함수를 다시 정의하고 있다. 이처럼 부모 클래스에서 자식 클래스의 함수를 다시 정의하여 함수 오버라이딩할 수 있다. 하지만 이 설명만으로는 조금 부족한 구석이 있다. 함수 오버로딩(Function Overloading)을 떠올려보자. 함수 오버로딩은 함수의 이름은 동일하고, 매개변수의 자료형이 다르거나 매개변수의 수가 다른 경우 였다. 만약 상속 관계에서 함수 오버로딩을 하면 매개변수에 맞는 오버로딩된 함수가 호출될 것이다. 상속 관계면 무조건 함수 오버라이딩이라고 생각해서는 안 된다. 함수 오버로딩도 상속 관계에서 이뤄질 수 있다는 것을 언급하고 싶었다.

 그럼 위의 클래스들을 참고하고 아래 메인 함수 코드를 살펴보자.
 각 클래스의 객체를 하나씩 생성하고 동일한 이름의 멤버함수인 MyFunc()을 호출하였다. 객체 포인터 참조 관계를 떠올려 보자. 혹시 모르는 사람은 링크를 꼭 살펴보길 바란다. 함수 오버라이딩과 객체 포인터 참조 관계를 모두 이해해야 C++의 다형성을 이해할 수 있다. 다시 돌아와서 tptr가 호출하는 함수는 Thrid 클래스의 MyFunc() 함수이다. sptr, fptr 역시 자료형에 맞는 자신의 멤버함수를 호출할 것이다. 어떻게 부모의 MyFunc() 함수가 아니라 자신의 MyFunc() 함수를 호출할 수 있을까?

 부모 클래스의 멤버함수를 자식 클래스에서 함수 오버라이딩하면 부모 클래스의 멤버함수는 가려지게 된다. 가려진다는 표현이 어색하게 들릴 수도 있다. 원래 부모 클래스의 멤버함수가 있다면 자식 클래스에서 당연히 호출할 수 있다. 하지만 자식 클래스에서 그 함수를 오버라이딩하면 그 멤버함수는 오버라이딩한 멤버함수에 의해 자신이 호출당할 기회를 양보해야 한다. 이를 좀더 비유적으로 말하자면 어떤 멤버함수를 호출하려고 불렀는데, 이 멤버함수는 알고보니 자신을 오버라이딩한 멤버함수가 있었다. 오버라이딩한 함수는 자신과 이름도 같았다. 함수 호출이 이뤄지자 오버라이딩한 멤버함수는 마치 호출된 것처럼 함수를 실행하였다. 이 멤버함수는 오버라이딩한 멤버함수에 의해 가려져서 호출되지 않는다. 그래서 가려졌다고 표현하였다. 결국 부모 클래스의 멤버함수가 오버라이딩되면 그 함수는 자식 클래스의 멤버함수에 의해 가려져서 자식 클래스의 객체가 호출하면 부모 클래스의 멤버함수는 실행되지 않고 자식 클래스에 오버라이딩한 함수가 호출된다.


다형성(polymorphism)으로...

 함수 오버라이딩과 객체 포인터의 참조 관계에 대한 지식을 토대로 다음 코드를 살펴보자.
 이번엔 Third 클래스의 객체를 생성하고, 이를 Third형 포인터, Second형 포인터, First형 포인터로 참조하고 있다. 이 경우 함수 오버라이딩은 어떻게 될까? 객체 포인터의 참조 관계를 잘 떠올려보자. 그럼 답이 나올지도 모른다.

 먼저 만들어진 객체는 Third 클래스의 객체이다. 이 객체는 부모 클래스의 멤버함수 MyFunc()을 오버라이딩하고 있다. 그렇다면 어떤 포인터로 호출하여도 Third 클래스의 MyFunc() 함수가 호출되어야 한고 생각할 수 있다. 이렇게 생각하면 틀렸다. 틀린 원인은 객체 포인터의 참조 관계를 제대로 적용하지 않았기 때문이다.

 객체 포인터의 참조 관계에 따르면 컴파일러는 자료형을 기준으로 포인터 연산의 가능 여부를 따진다. 실제 참조하는 객체가 무엇인지 중요하지 않다. fptr은 First의 멤버함수를,  sptr은 Second의 멤버함수를 호출하려는데 이 함수가 오버라이딩되었으므로 자신의 멤버함수를, tptr는 Third의 멤버함수를 호출하려는데 이 함수가 오버라이딩되었으므로 자신의 멤버함수를 호출한다.


정리하면

  • 상속 관계에서 자식 클래스에서 부모 클래스의 멤버함수와 동일한 이름의 함수를 정의하는 것을 함수 오버라이딩이라고 한다.(단, 함수 오버로딩의 경우과 구별할 수 있어야 한다.
  • 객체 포인터의 참조 관계와 함수 오버라이딩에 대한 기본적인 이해를 토대로 C++의 다형성을 제대로 알 수 있다.

2015. 2. 4.

[C++]객체 포인터의 참조관계

 객체 포인터의 참조관계는 C++의 다형성(polymorphism)을 이해하는 출발점이다. 참조관계를 이해하기 위해서는 상속에 대한 지식이 필요하다. 지식이 필요하다고 해서 엄청나게 거창한 내용들이 아니다. '상속'하면 떠오르는 'A is a B'만 알고 있어도 크게 무리는 없다. 그럼 상속의 개념이 객체 포인터의 참조관계에 어떻게 적용되는지 살펴보자.


상속과 포인터 참조 관계

 'A is a B'는 B는 A의 한 종류라는 의미이다. 예를 들면 학생은 사람의 한 종류라는 말은 당연하게 느껴질 것이다. 축구선수는 사람의 한 종류라는 말도 마찬가지이다. 이걸 이해하지 못하는 사람은 없을 것이라 생각한다. 이 익숙한 개념이 객체 포인터의 참조관계에도 그대로 적용된다. 객체 포인터의 참조관계라는 긴 용어가 익숙하지 않을지도 모르겠다. 단순하게 말하면 객체를 가리키는 포인터를 사이에 어떤 관계가 있는가 정도로 설명할 수 있다. 용어는 중요한 것이 아니니 코드로 먼저 보자.

 주석을 보면 디폴트 생성자디폴트 소멸자에 대한 언급이 보일 것이다. 궁금한 사람은 링크를 찾아가보자. 중요한 부분은 new 연산자를 통해 객체를 생성하는 곳이다. Person의 포인터로 Person의 객체를 가리키는 것은 너무나 자연스러운 일이다. 그 다음 줄을 보면 Person의 포인터로 Student의 객체를 가리키고 있다. 이는 너무나 자연스러운 일이 아니다. 이것이 자연스럽다면 아래의 내용을 볼 필요가 없다. 상속과 객체 포인터의 관계를 잘 이해하고 있기 때문이다.

 부모 클래스 Person의 포인터로 자식 클래스 Student의 객체를 가리키는 것이 가능할까? 가능하다. 상속의 개념을 떠올려보자. Student는 Person의 한 종류이다. 이 개념을 포인터로 확장하면 된다. Person이 Student가 될 수 있듯이, Person의 포인터는 Student의 객체를 가리킬 수 있다. 상속의 개념을 포인터에 그대로 적용하면 된다. 이는 C++만의 특징이기도 하다. 다시 말해 C++에서는 클래스 A의 포인터는 A의 객체는 물론이고, A로부터 상속받은 클래스들도 참조할 수 있다. 문법적인 원리를 따지려기보다는 상속의 개념을 C++에 적용했다고 생각하는 것이 좋다. C++의 다양한 컴파일러가 이런 일을 가능하도록 해주고 있으니 우리는 이해하기 위해서 공부하고 있다라고 생각해도 나쁘지 않겠다.

 Person <- Student <- ElementalStudent의 구조로 상속 관계가 이루어져 있다. 일반적으로 상속 관계를 나타낼 때는 화살표를 부모 방향으로 표시한다. 뜬금없는 ElementalStudent는 마법을 배우는 학생이다. 갑자기 떠올라서 그냥 그렇게 썼다. 여튼 상속을 하면 부모 클래스의 멤버함수, 멤버변수 모두 상속받는다. 그러니 자식 클래스의 객체에서 부모 클래스의 멤버함수를 당연히 호출할 수 있다.

 그렇다면 이런 경우는 어떨까? 부모 클래스의 포인터를 이용하여 자식 클래스의 멤버함수를 호출하려고 한다. 상속의 개념에 따르면 부모 클래스의 포인터는 자식 클래스를 참조하는 것이 아주 당연하다. 그렇다면 자식 클래스의 함수 또한 실행시킬 수 있지 않을까? 다음 코드를 보자.

 Person의 포인터로 Student의 객체를 참조하고 있다. 이는 앞에서 말했듯이 아무런 문제가 없다. Student의 포인터로 ElementalStudent의 객체를 참조하고 있다. 이 역시 아무런 문제가 없다. 문제는 다음에서 발생한다. Person 포인터 pPtr2는 Student를 참조하고 있음에도 Student의 멤버함수를 호출할 수 없다. Student 포인터 pPtr3은 ElementalStudent를 참조하고 있음에도 ElementalStudent의 멤버함수를 호출할 수 없다. 분명히 'A is a B'이고, 그래서 부모는 자식이 될 수 있다고 말했다. 그런데 현실은 늘 만만치 않듯이 이 경우도 우리 뜻대로 되지 않는다.


컴파일러님을 조금이나마 이해해보자

 상속과 객체 포인터 참조관계가 그러하듯이 이번에도 컴파일러의 뜻을 조금 이해할 필요가 있다. C++ 컴파일러는 포인터 연산의 가능성 여부를 판단할 때, 포인터의 자료형을 기준으로 판단하지, 실제 가리키는 객체의 자료형을 기준으로 판단하지 않는다. 이건 매우 중요하다. 이걸 이해하지 못하면 앞으로 C++은 외계인의 언어처럼 느껴질 것이다. 차분하게 다시 살펴보자.
 주석으로 case1이라고 표시된 부분을 보자. 포인터의 자료형은 부모 클래스의 포인터인 Person의 포인터 pPtr2이다. pPtr2가 참조하는 객체는 무엇인가? Student 클래스의 객체이다. 그리고 pPtr2로 Student 클래스의 멤버함수를 호출하려는 상황이다. 이 때 컴파일러는 무슨 생각을 하는지 조금이나마 헤아려보자. 컴파일러는 이렇게 생각할 것이다.
'pPtr2는 확실히 Person의 포인터네. 그럼 pPtr2가 참조할 수 있는 대상은 뭐가 있지? Person의 객체랑 Student의 객체랑 ElementalStudent의 객체를 참조할 수 있겠어. 호출한 함수를 보자. 어?! Play() 함수를 호출했네? 나는 지금 pPtr2가 실제로 어떤 객체를 참조하고 있는지 모르는데?? 어떻게 하나??? 혹시나 pPtr2가 참조하고 있는 객체가 Person의 객체면 Play() 함수는 당연히 호출 못하지. 부모 객체가 어떻게 자식 클래스의 멤버함수를 호출하겠어? 그래. 나는 언제나 최악의 상황은 막아야 하니깐 에러를 발생시키자. 깔끔하게 컴파일 에러로 정했어!'
 컴파일러는 이런 식으로 생각한다. 어찌보면 합리적인 선택이다. 그런데 여기서 생길 수 있는 의문은 pPtr2는 분명 Student의 객체를 참조하고 있는데 컴파일러는 그걸 왜 모르냐는 점이다. 컴파일러는 불필요한 포인터 연산을 허용하지 않음으로 문제를 발생 확률을 최소화하도록 정해진 C++의 문법에 근거해서 이런 판단을 한다. Student의 객체가 생성되었다고 판단하는 것은 프로그램이 실행된 후에 실제로 확인할 수 있는 점이기도 하다. 여튼 컴파일러는 이렇게 판단한다.


 갑자기 너무나 중요한 두 가지 사항이 등장했다. 한 번 정리하고 넘어가자.

  1. C++에서는 클래스 A의 포인터는 A의 객체는 물론이고, A로부터 상속받은 클래스들도 참조할 수 있다.
  2. C++ 컴파일러는 포인터 연산의 가능성 여부를 판단할 때, 포인터의 자료형을 기준으로 판단하지, 실제 가리키는 객체의 자료형을 기준으로 판단하지 않는다.
 이제 우리는 이 두 가지 원칙을 기준으로 객체 포인터의 참조관계를 따지면 된다. 

 위의 코드를 다시 살펴보자. 이번엔 case2이다. 결국은 같은 상황이다. pPtr3는 Student의 포인터이고, 자식 클래스인 ElementalStudent의 객체를 참조한다. 중요한 사실은 1번 원칙에 따라서 pPtr3은 ElementalStudent의 객체를 참조할 수 있다는 점이다. 그럼 pPtr3을 이용하여 Cast() 함수를 호출하는 것은 어떠한가? 포인터 연산의 가능 여부는, 즉 멤버함수 호출도 해당하는데, 포인터의 자료형을 기준으로 판단한다고 그랬다. 2번 원칙이다. pPtr3은 Student 클래스의 포인터이다. 포인터의 자료형을 기준으로 보면 pPtr3이 호출할 수 있는 함수는 Eat(), Play() 두 개이다.

정리하면
  • 포인터로 객체을 참조할 수 있느냐 없느냐는 상속의 개념을 판단할 수 있다.
  • 포인터로 멤버함수를 호출할 수 있느냐 없느냐는 전적으로 포인터 자료형을 기준으로 판단한다. 실제 참조하는 객체가 무엇인지는 컴파일러가 알지 못한다.

2015. 2. 3.

[C++]상속(inheritance)와 객체 생성, 소멸 과정

 상속에 대한 세부적인 설명은 생략하고, 상속한 클래스의 객체를 생성하고, 소멸하는 과정이 어떻게 진행되는지 살펴보고자 한다. 그리고 이 글은 생성자소멸자에 대한 기본적인 이해가 필요하니 링크를 참고하기 바란다. 복사생성자까지 이해하고 있다면 참 좋은데... 아니다. 생각해보니 복사생성자에 대한 이해도 필요하다. 링크를 참고해서 복사생성자도 보도록 하자.


상속(inheritance)

 여러 자료를 보면 상속을 'A is a B'의 관계로 설명하곤 한다.


 그림을 보면 자전거가 부모 클래스이고, 산악 자전거, 도로 자전거, 세 번째는 무슨 자전거지? 찾아보기 귀찮으므로 땡땡 자전거. 여튼 산악, 도로, 땡땡 자전거들은 자전거로부터 파생된 것들이다. 그래서 "산악 자전거는 자전거의 하나이다.", "도로 자전거는 자전거의 일종이다.", 즉 'A is a B' 관계가 성립한다. 상속을 간략하고 투박하게 설명하면 이런 것이다. 상속에 대핸 자료는 많고, 쉽게 접할 수 있느니깐 그만 하도록 하고 넘어가자.

 우리가  상속 자체보다는 상속받은 클래스(자식 클래스)의 객체는 부모 클래스의 객체와 무엇이 다른지에 주목하고자 한다.


자식 클래스의 생성 과정

 먼저 상속을 설명하면서 처음부터 끝까지 사용할 코드를 살펴보자. 그림이 자전거이니 자전거로 클래스를 만들었다.
 생성자, 소멸자에 대해 이해하고 있다면 간단한 코드이다. 전과 달라진 점은 MTBicycle 클래스가 Bicycle 클래스를 상속받고 있다는 점 뿐이다. Bicycle 클래스는 생성자에서 자신의 이름과 속력을 초기화한다. MTBicycle 클래스의 생성자를 살펴보면 멤버 이니셜라이저 부분에서 자신의 부모 클래스인 Bicycle 클래스의 생성자를 호출하고 있다.

 전에 객체 생성은 크게 세 단계를 거친다고 언급한 적이 있다.
  1. 객체를 위한 메모리 공간 할당
  2. 멤버 이니셜라이저를 통한 초기화
  3. 생성자 몸체 부분 실행 
 MTBicycle 클래스의 생성 과정을 위의 세 단계를 적용해서 살펴보자. 객체를 위한 메모리 공간이 할당된다. 그 다음 생성자의 멤버 이니셜라이저를 먼저 살펴본다. 그런데 멤버 이니셜라이저에서 부모클래스의 생성자가 선언되어 있다. 이것을 먼저 실행해줘야 한다. 그래서 부모 클래스의 생성자가 자신의 생성자보다 먼저 호출되게 된다. 이는 매우 중요한 사실이므로 잘 이해하도록 하자. 부모 클래스의 생성자를 보니 멤버 이니셜라이저가 또 존재한다. speed을 초기화하라는 내용이다. 간단히 speed를 초기화해주고 나면 생성자 몸통에서 name을 위한 공간을 동적 할당하고 초기화해준다. 부모 클래스의 생성자 호출이 완료되면 비로소 자신의 생성자를 호출하여 전체 객체 생성 과정을 완료하게 된다.

 부모 클래스로부터 상속받은 클래스의 객체를 생성하는 경우, 자신의 생성자만 호출한다고 생각하기 쉽다. 자식 클래스의 객체를 생성하기 위해서는 반드시 부모 클래스의 생성자가 호출됨을 잊으면 안된다.

 설명한 내용을 바탕으로 main() 함수를 실행시켜 보자.
 내용은 간단하다. 자식 클래스인 MTBicycle의 객체를 하나 생성한다. 생성자와 소멸자에 각각 자신의 생성자가 호출되었다는 것을 출력하는 코드를 넣어두었다. 결과가 어떻게 나올지 예상해보자. 앞에서 언급한 내용에 따르면 자식 클래스의 객체를 생성하기 위해서는 먼저 부모 클래스의 생성자가 호출된다. 그럼 MTBicyle 클래스의 객체가 생성자가 호출되기 전에 Bicycle 클래스의 생성자가 호출된다.

 그런데 이런 의문이 생길지도 모른다. 자식 클래스의 멤버 이니셜라이저에 부모 클래스의 생성자를 넣어두었으니깐 당연히 부모 클래스의 생성자가 호출되는 것이 아니냐고. 직접 클래스를 만들어서 해보면 확인할 수 있을 것이다. 멤버 이니셜라이저에서 부모 클래스가 호출되지 않아도 자식 클래스의 객체를 생성하기 위해서는 부모 클래스의 생성자가 먼저 반드시 호출된다는 사실을 알 수 있다.


자식 클래스의 소멸 과정

위 코드를 실행해본 사람이라면 소멸 과정이 생성 과정이 다르다는 점을 이미 확인했을 것이다. 위의 두 클래스는 꼼꼼하게도 소멸자에서 new 연산자로 동적 할당한 메모리를 반환하고 있다. 그러면 생성 과정과 어떻게 다른지 알아보자. 결론부터 말하면 소멸 순서는 생성 순서와 반대이다. 생성 과정은 부모에서 자식 순서로 진행되지만, 소멸 과정은 자식에서 부모 순서로 진행된다. 이 순서로 소멸자를 호출하면서 소멸자에 내부에 코드가 있다면 그 코드를 실행시킨다. 위의 두 클래스는 모두 소멸자에서 자신이 생성자에서 동적 할당한 메모리를 스스로 반환하도록 만들어져있다. 객체 소멸 과정에서 메모리 누수가 발생하지 않도록 하기 위함이다.


정리하면
  • 자식 클래스의 객체를 생성 과정은 부모로부터 자식 순서로 생성자를 호출한다.
  • 자식 클래스의 객체를 생성 과정은 자식으로부터 부모 순서로 소멸자를 호출한다.