다형성,추상 클래스

|

다형성(polymorphism)이란, 상속에서 쓰이는 용어로,base class의 레퍼런스나 포인터를 통해 가상함수(virtual function)를 호출 했을 때, 그 레퍼런스나 포인터가 가리키고 있는 객체에 알맞게 함수를 유동적으로 선택하는 것을 말한다.
즉, 파생 클래스의 멤버함수가 부모 클래스의 포인터나 레퍼런스를 거쳐 호출되는 것을 지칭한다.

본래 포인터나 레퍼런스는, 각각의 타입에 맞는 것만 가리킬 수 있다.
예를 들어 int형 포인터는 int형 값의 주소만 담을 수 있는 것이다.

하지만 상속의 경우에는, base class의 객체 포인터는 파생 클래스의 객체의 주소를 담을 수 있다.
그도 그럴것이, 파생 클래스는 base class와 "is a" 관계, 즉 파생 클래스의 객체도 부모 클래스의 객체로 판단할 수 있기 때문에,주소를 담을 수 있는 것이다.
하지만 파생 클래스의 포인터는 부모 클래스의 객체의 주소를 담지 못한다.

즉,
baseObj와 deriveObj가 있다면,
basePtr = &baseObj , basePtr = &deriveObj 는 가능하지만,
derivePtr = &deriveObj만 가능하고 derivePtr = &baseObj는 불가능하다는 것이다.

(자동차는 탈것과 = 관계가 성립하지만 탈것 = 자동차는 성립하지 않는다는 것을 상기해보자.)

예제를 하나 보자.

 

#include <iostream>
using namespace std;
class base
{
public:
 int a,b;
    base(int x,int y)
 {
  a=x;
  b=y;
 }
 void func()
 {
  cout<<"base function"<<endl;
 }
private:
 int c;
};

class derive : public base
{
public:
 int d;
 
 derive (int x,int y)
 :base(x,y)
 {
 
 }
 void func()
 {
  cout<<"derive function"<<endl;
 }

};

int main()
{
 derive t1(5,6);

 base *ptr = &t1;

 ptr->func();


 return 0;
}

 

 

예제를 보면 base class와 derive class가 있고, 둘은 이름과 매개변수가 같은 함수 func를 갖고 있다.
여기서 잠깐, 오버라이딩(overriding)과 오버로딩(overloading)에 대해 알아보자.
오버로딩은 무엇인고? "이름" 이 같은 함수를 여러개 두어 상황에 맞게 골라 쓰기 위해 함수를 중복적으로 정의하는 것이다.
여기서 매개변수의 자료형과 개수,순서는 서로 달라야 함수끼리 구별이 되어 골라 쓸 수 있다.

그렇다면 오버라이딩은?
처음 봤을때는 오버로딩을 잘못 쓴건줄 알았다.
오버라이딩은 상속에서 사용되는 용어로써, 오버로딩이 매개변수가 차이가 나야 하는 반면,오버라이딩은 리턴타입-함수 이름-매개변수(타입 순서 개수) 전부 똑같지만 기능이 다른 경우를 의미한다.
즉 부모 클래스에서 상속받은 멤버 함수를 자식 클래스에서 새로 정의하는 것이다.

위의 예제에서는 func가 바로 오버라이딩 된 것이라고 볼 수 있겠다.
(정확히는 virture이 빠져있으므로 완벽하게는 오버라이딩이라고 볼 수 없다. 정확한 정의는 기반함수의 가상함수를 파생 클래스에서 재정의하는 것을 오버라이딩이라고 한다.)

둘은 엄밀히 다른 것이므로 사용시 유의하자.


아무튼, main을 보면 base 타입의 포인터 ptr을 선언하여 derive클래스 객체의 주소를 가리키게 했다.
그리고 -> 연산자로 접근하여 객체의 func함수를 호출했다.

과연 어느쪽의 함수가 호출될까? base? derive?

derive객체의 주소를 담고 있으니까 derive쪽의 func가 호출되어야 하지 않을까?



하지만 결과는 base 쪽의 func가 호출된다.

가상함수를 선언해주지 않으면, 가리키고 있는 대상이 아닌, 함수를 호출하는 핸들(객체이름이나 레퍼런스,혹은 포인터)이 어떤 것이냐(핸들의 자료형이 어떤 것이냐)에 따라 어느 함수를 호출할지 결정된다.
위의 경우에서는 핸들이 base 타입의 포인터이므로 핸들이 어디 소속이냐에 충실하게 base 클래스의 func를 호출한 것이다.

아니 그럼 derive 클래스 포인터로 지정해서 쓰면 되지!
라고 말할 수 있다.
하지만 하나의 부모 클래스에 여러개의 derive 클래스가 상속관계를 갖고 있다고 생각해보라.
일일히 하나씩 다 포인터 만들어 줄껀가?
부모 클래스 포인터 하나 만들어서 다 지정하면 짱 편하지.

바로 여기서 가상함수가 쓰이게 된다.

가상 함수란 함수를 선언할 때 함수 return type앞에 virtual 키워드가 추가된 형태를 말한다.위에도 서술했듯이 같은 리턴형태-이름-매개변수를 갖는 상속하는 멤버 함수 앞에 virtual 키워드를 붙이면 함수 오버라이딩이 된다.
virtual 키워드는 base class의 멤버 함수에 붙이면 승계받는 클래스 쪽에 주루룩 같이 상속을 받지만, 햇갈릴 수 있으니 보통 앞에 다  붙여주는 것이 좋다.

잠깐 여기서 동적 결합(dynamic binding)과 정적 결합(static binding)에 대해 알아보자.
정적 결합이란 핸들이 객체 이름일때,즉 . 연산자로 함수에 접근 할 때에는 컴파일 시에 해당 함수가 어느 클래스의 함수인지 매칭시키는 것이다.
반대로 동적 결합이란 핸들이 레퍼런스나 포인터일때,(물론 가상함수도 설정되어 있어야 함) 매칭되는 함수가 어느 클래스의 함수인지 결정 짓는 것을 컴파일 타임 때가 아닌 런타임때 결정하는 것을 말한다.

 

다시 예제로 돌아가서,

#include <iostream>
using namespace std;
class base
{
public:
 int a,b;
    base(int x,int y)
 {
  a=x;
  b=y;
 }
 virtual void func()
 {
  cout<<"base function"<<endl;
 }
private:
 int c;
};

class derive : public base
{
public:
 int d;
 
 derive (int x,int y)
 :base(x,y)
 {
 
 }
 void func()
 {
  cout<<"derive function"<<endl;
 }

};

int main()
{
 derive t1(5,6);

 base *ptr = &t1;

 ptr->func();


 return 0;
}


 

 

이렇게 base class의 함수앞에 virtual 키워드만 붙여놓으면 해결이다.(헤더에만 선언해주고 정의부는 써주지 않는다)

가상함수에 들어가기 앞서, 가상함수 테이블에 대해 알아보자.
일단 클래스 내에 가상함수가 한개라도 존재하면 가상 함수 테이블이 존재하게 된다.(물론 base 부분에서)
이것은 함수의 이름과 인덱스 넘버,함수의 주소를 써놓은 리스트인데, 클래스별로 한개씩 갖고 있게 된다.
가상함수 테이블을 가지게 되면 요 테이블의 위치를 기억할 포인터가 한개 자동으로 추가되는데, 실제로 sizeof로 객체의 크기를 찍어보면
virtual이 추가된 클래스와 추가되지 않은 클래스는 크기가 4 차이가 난다.(포인터는 타입불문 4바이트)

바로 이 가상함수 테이블이 생겨나기 때문에 컴파일러가 런타임 시에 이 테이블을 참조하여 객체에 맞게 함수를 호출하는 것이다.
(주의할 것은 가상함수 테이블은 클래스마다 한개라는 것.base ptr이긴 하지만 그전에 t1은 derive 클래스이므로 derive 클래스의 가상함수 테이블을 따른다. 즉 포인터가 무엇이냐(handle이 무엇이냐)가 아닌, 객체의 타입이 어떤 타입이냐에 따라 함수가 결정되는것이다.)

 

 

(클릭시 커짐)

디버그 모드로 들어가 확인해보면,
일단 ptr은 둘 다 base 타입인데도 불구하고 참조하는 가상함수 테이블 포인터가 가리키는 것은 (_vfptr)은 각각 가리키는 객체에 따라 다른 것을 확인할 수 있다.
또한 각각의 가상함수 테이블 포인터가 담고 있는 함수 인덱스 [0]을 보면 같은 func지만, ptr쪽은 derive의 func , ptr2 쪽은 base쪽의 func를 가리키고 있는 것을 볼 수 있다.

 

그리하여 결론은,
가상함수를 선언해놓으면 클래스별로 가상함수 테이블이 생기게 되고,
포인터나 레퍼런스를 통해 멤버 함수에 접근하게 되면 동적 결합이 되어 런타임시에 어떤 함수를 실행할 지 결정이 되는데,그 때 호출하는 쪽의 객체가 어떤 클래스냐에 따라 요 가상함수 테이블을 참조해서 맞는 함수를 실행시킨다...정도 되겠다.

 

 

이렇게 상속에서 포인터를 통해 멤버 함수에 접근하는 것에는 제한이 있는데,위에도 서술했듯이
1. 파생 클래스의 포인터가 부모 클래스 객체를 가리키지 못한다.
2. 부모 클래스의 포인터를 통해 파생 클래스 객체의 멤버 함수를 사용하려고 해도, 그 멤버 함수가 부모 클래스에 정의되어 있지 않은 파생 클래스만의 함수라면 사용이 불가하다.

라는 제약이 있다.

여기서 2에 대해서 예외가 있는데,

일단 거기에 들어가기 앞서(...)

upcasting과 downcasting에 대해 알아보자.
cast란 형변환으로써 c++에는 크게 4개의 형변환, - dynamic_cast , const_cast, static_cast, reinterpret_cast 가 있다.

나머지는 후일 따로 다루기로 하고,
형변환이란 무엇인가?
어떤 타입의 것을 다른 타입의 것으로 만드는 것을 말한다.

업캐스팅이란 하위의 것을 상위의 것으로 변환시키는 것,
다운캐스팅이란 상위의 것을 하위의 것으로 변환시키는 것.

즉 상속에서는 파생 클래스 유형을 부모 클래스로 변환시키는 것이 업캐스팅이고,
부모 클래스 유형을 파생 클래스 유형으로 변환시키는게 다운캐스팅이다.


base *ptr = new derive(5,6);

그냥 이게 업캐스팅이다.
파생 클래스를 부모 클래스 포인터에 할당했으니, 개념이 위로 상승했다고 볼 수 있다.

이런 업캐스팅에서는 별도의 형변환을 명시적으로 해줄 필요가 없고, 문제가 되지 않는다.

하지만 다운캐스팅에서는 dynamic_cast 연산자를 명시해주어야 하는데...

( 이부분 추후에 보강) - 자바 책 331 참조

 

 

 

이제 추상 클래스(abstract class)에 대해 알아보자.

추상 클래스란 객체 생성을 위해 작성되는 것이 아니라 상속에서 기반 클래스 역할을 하도록 만들어진 것으로, 실제 객체 생성은 할 수 없지만(하지만 레퍼런스나 포인터는 생성 가능하다.) 파생클래스들에게 말그대로 기반 역할을 하게 하는 역할을 하는 클래스라 할 수 있다.

그렇다면 추상 클래스의 조건은 무엇인가?
바로 순수 가상함수다.
이 순수 가상함수가 최소 한개 이상 있는 클래스를 추상 클래스라 한다.
순수 가상함수의 선언은 아주 간단한데, 기존 가상 함수 선언 방식에 제일 뒤에 = 0만 붙이면 된다.

virtual func() = 0;

이런식.
순수 가상함수는 헤더만 존재하고 내부 기능은 정의하지 않는다. 즉 바디가 없다.(물론, 추상 클래스에는 일반적인 함수나 다른 데이터 멤버들도 같이 존재할 수 있다.가상함수로만 이루어지지 않아도 된다.)이는 인터페이스만 제공해주고 파생 클래스에서 오버라이딩해 쓰도록 하기 위함이다.
순수 가상함수와 가상함수의 차이는, 내부 기능이 정의되어 있느냐 있지 않느냐의 차이다. 둘 다 파생 클래스에서 오버라이딩 하긴 하지만.
단 주의할 것은, 만약 base에서 순수 가상함수를 설정했다면, 그에게서 파생되는 파생 클래스는 하나의 클래스도 빠짐없이 전부 이 가상함수를 오버라이딩해줘야 한다. 만일 오버라이딩 되어 있지 않으면 해당 클래스의 객체를 만드는데 컴파일 에러가 발생한다.




class base
{
public:
 virtual void test () =0;

};

class derive : public base
{
public:

 void test()
 {
  cout<<"this is overridden function"<<endl;
 };

};

 

간략하게 예를 보면 , base클래스의 test 함수는 기능이 전혀 정의되어 있지 않고, 헤더만 선언되어 있다.
이를 파생 클래스에서 오버라이딩해서 기능을 구현해 사용하는 것이다.
순수 가상함수나 가상함수나 구현에 있어 그렇게 큰 차이는 없다.

추상 클래스는 객체는 만들지 못하지만, 레퍼런스나 포인터를 생성해 파생 객체를 지정하는데 쓰일 수 있다.



 

신고

'ComputerEngineering > C/C++' 카테고리의 다른 글

stream Input/Output(C++)  (0) 2012.12.11
템플릿.  (0) 2012.12.11
다형성,추상 클래스  (1) 2012.12.10
동적 할당(C++)  (0) 2012.12.04
객체의 크기  (0) 2012.11.29
멤버를 reference로 return하는 것의 위험성.  (0) 2012.11.20
trackback 0 And comment 1