문장은 같은데 결과는 다르다.
class Fisrt
{
public:
virtual void simpleFunc()
{
cout << "First" << endl;
}
};
class Second
{
public:
virtual void simpleFunc()
{
cout << "Second" << endl;
}
};
int main(void)
{
First *ptr = new First;
ptr->simpleFunc();
delete ptr;
ptr = new Second;
ptr->simpleFunc();
delete ptr;
return 0;
}
위 main함수에서는 다음의 문장이 두 번 등장한다.
ptr->simpleFunc();
그런데 ptr
은 동일한 포인터 변수이다. 그럼에도 불구하고 실행결과는 다를 수 있다. 포인터 변수 ptr이 참조하는 객체의 자료형이 다르기 때문이다. 이것이 바로 다형성이다. 다음 설명을 따라가며 자세히 알아보자.
클래스를 기반으로도 포인터 변수를 선언할 수 있다. 예를 들어 Person이라는 이름의 클래스가 정의되었다면, Person 객체의 주소 값 저장을 위해 다음과 같이 포인터 변수를 선언할 수 있다.
Person *ptr = new Person();
위 문장이 실행되면, ptr은 Person 객체를 가리키게 된다. 그런데 Person형 포인터는 Person 객체 뿐만 아니라, Person을 상속하는 유도 클래스의 객체도 가리킬 수 있다.
다음과 같은 Student 클래스가 정의되어 있을 때,
class Student: public Person
{
...
};
다음이 가능하다.
Person *ptr = new Student();
그리고, 다음과 같이 Student를 상속한 PartTimeStudent가 정의되어 있을 때,
class PartTimeStudent: public Student
{
...
};
다음도 가능하다.
Person *ptr = new PartTimeStudent();
즉, 다음과 같이 정리할 수 있다.
C++에서 AAA형 포인터 변수는 AAA 객체 또는 AAA를 직접 혹은 간접적으로 상속하는 모든 객체를 가리킬 수 있다(객체의 주소값을 저장할 수 있다).
위는 상속의 IS-A 관계를 통해 논리적으로 이해가 가능하다.
바꿔 말하면,
위 문장이 성립함으로 인해, Student 객체와 PartTimeStudent 객체를 Person 객체의 일종으로 간주한다. 때문에 Person형 포인터 변수를 이용해서 Student 객체와 PartTimeStudent 객체를 가리킬 수 있는 것이고, Student형 포인터 변수를 이용해서 PartTimeStudent 객체를 가리킬 수 있는 것이다.
다음과 같이 정의된 클래스가 있을 때,
class Base
{
public:
void baseFunc(){ cout << "Base" << endl;}
};
class Derived: public Base
{
public:
void derivedFunc(){ cout << "Derived" << endl;}
};
다음은 문제없이 컴파일 한다.
int main(void)
{
Base *bptr = new Derived();
}
하지만 다음은 컴파일 에러가 발생한다.
int main(void)
{
Base *bptr = new Derived();
bptr->derivedFunct(); // 컴파일 에러!
}
왜냐하면 bptr이 Base형 포인터이기 때문이다.
C++ 컴파일러는 포인터 연산의 가능성 여부를 판단할 때, 포인터의 자료형을 기준으로 판단하지, 실제 가리키는 객체의 자료형을 기준으로 판단하지 않는다.
따라서 다음도 컴파일 에러를 일으킨다.
int main(void)
{
Base *bptr = new Derived();
Derived *dptr = bptr; // 컴파일 에러
}
컴파일러가 다음과 같이 판단하기 때문이다.
"bptr은 Base형 포인터니까, bptr이 가리키는 대상은 Base객체일 수도 있으니 컴파일 에러를 발생시켜야겠다!"
다음과 같이 정의된 클래스들이 있을 때,
class First
{
public:
void firstFunc(){ cout << "First" << endl; }
};
class Second: public Fisrt
{
public:
void secondFunc(){ cout << "second" << endl;}
};
class Third: public Second
{
public:
void thirdFunc(){ cout << "Third" << endl; }
};
다음은 아무런 문제도 일으키지 않는다.
int main(void)
{
Third *tptr = new Third();
Second *sptr = tptr;
First *fptr = sptr;
}
그러나 다음에서는 문제가 생긴다.
int main(void)
{
Third *tptr = new Third();
Second *sptr = tptr;
First *fptr = sptr;
tptr->firstFunc(); // (O)
tptr->secondFunc(); // (O)
tptr->thirdFunc(); // (O)
sptr->firstFunc(); // (O)
sptr->secondFunc(); // (O)
sptr->thirdFunc(); // (X)
fptr->firstFunc(); // (O)
fptr->secondFunc(); // (X)
fptr->thirdFunc(); // (X)
}
정리하면, 포인터 형에 해당하는 클래스에 정의된 멤버에만 접근이 가능하다.
class First
{
public:
void myFunc(){ cout << "First" << endl; }
};
class Second: public Fisrt
{
public:
void myFunc(){ cout << "Second" << endl;}
};
class Third: public Second
{
public:
void myFunc(){ cout << "Third" << endl; }
};
int main(void)
{
Third *tptr = new Third();
Second *sptr = tptr;
First *fptr = sptr;
fptr->myFunc();
sptr->myFunc();
tptr->myFunc();
delete tptr;
return 0;
}
실행결과
First
Second
Thrid
실행결과를 보면 다음과 같이 설명할 수 있다.
First형 포인터 변수를 이용하면 First 클래스에 정의된 myFucn 함수가 호출되고, Second형 포인터 변수를 이용하면 Second 클래스에 정의된 myFucn 함수가 호출되고, Third형 포인터 변수를 이용하면 Third 클래스에 정의된 myFucn 함수가 호출되는구나!
sptr->myFunc();
을 예로 조금 더 자세히 살펴보면, 컴파일러는 다음과 같은 판단을 한다.
sptr이 Second형 포인터이니, 이 포인터가 가리키는 객체에는 First의 myFunc함수와 Second의 myFunc 함수가 오버라이딩 관계로 존재하는구나. 그럼 오버라이딩을 한 second의 myFunc 함수를 호출해야겠다.
하지만 다음과 같은 생각을 할 수 있다.
함수를 오버라이딩 했다는 것은, 해당 객체에서 호출되어야 하는 함수를 바꾼다는 의미인데, 포인터 변수의 자료형에 따라 호출되는 함수의 종류가 달라지는 것은 문제가 있지 않을까?
이러한 문제를 해결하기 위해 C++에서는 가상함수라는 것을 제공한다.
가상함수의 선언은 다음과 같은 키워드를 통해 이뤄진다.
class First
{
public:
virtual void myFunc(){ cout << "First" << endl; } // virtual 키워드 삽입
};
위와 같이 First 클래스의 myFunc 함수가 virtual로 선언되면, 이를 오버라이딩 하는 Second 클래스의 myFunc함수도, 그리고 이를 오버라이딩 하는 Third 클래스의 myFunc 함수도 가상함수가 된다.
다음의 예제를 통해 가상함수의 특성을 관찰해보자.
class First
{
public:
virtual void myFunc(){ cout << "First" << endl; }
};
class Second: public Fisrt
{
public:
void myFunc(){ cout << "Second" << endl;}
};
class Third: public Second
{
public:
void myFunc(){ cout << "Third" << endl; }
};
int main(void)
{
Third *tptr = new Third();
Second *sptr = tptr;
First *fptr = sptr;
fptr->myFunc();
sptr->myFunc();
tptr->myFunc();
delete tptr;
return 0;
}
실행결과
Third
Third
Thrid
위의 예제를 통해 다음과 같이 정리할 수 있을 것이다.
함수가 가상함수로 선언되면, 해당 함수 호출 시, 포인터의 자료형을 기반으로 호출대상을 결정하지 않고, 포인터 변수가 실제로 가리키는 객체를 참조하여 호출 대상을 결정한다.
클래스 중에서는 객체생성을 목적으로 정의되지 않는 클래스도 있다. 다음 클래스는 상속을 받은 클래스에서 멤버 함수를 오버라이딩하여 이용하기 위해 만들어진 클래스이다.
class Employee
{
private:
char name[100];
public:
Employee(char *name) { ... }
void showYourName() const { ... }
virtual int getPay() const
{ }
virtual void showSalaryInfo() const
{ }
};
위와 같이 객체 생성을 목적으로 하지 않는 클래스도 다음의 문장이 전혀 문제 없이 실행된다.
Employee * emp = new Employee("hyeonkim"):
객체 생성을 목적으로 하지 않은 클래스이지만 위의 문장을 실행하는 데에 전혀 문제가 없는 것이다. 이를 막기 위해 가상함수를 순수 가상함수로 선언하여 객체 생성을 문법적으로 막는 것이 좋다.
순수 가상함수란 함수의 몸체가 정의되지 않은 함수이다.
class Employee
{
private:
char name[100];
public:
Employee(char *name) { ... }
void showYourName() const { ... }
virtual int getPay() const = 0; // 순수가상함수
virtual void showSalaryInfo() const = 0; // 순수가상함수
};
이를 표현하기 위해 위에서 = 0;
을 추가했다. 이것은 0의 대입을 의미하는 것이 아니고, '명시적으로 몸체를 정의하지 않았음'을 컴파일러에게 알리는 것이다. 따라서 컴파일러는 이 부분에서 함수의 몸체가 정의되지 않았다고 컴파일 오류를 일으키지는 않는다. 그러나 Employee클래스는 순수 가상함수를 지닌, 완전하지 않은 클래스가 되기 때문에 객체를 생성하려 들면 컴파일 에러가 발생한다.
그리고 이렇듯 하나 이상의 멤버 함수를 순수 가상함수로 선언한 클래스를 가리켜 추상클래스(abstract class)라고 한다.
가상함수 말고도 virtual 키워드를 붙여줘야 할 대상이 하나 더 있다. 바로 소멸자이다.
virtual로 선언된 소멸자를 가리켜 '가상 소멸자'라고 하는데, 이의 필요성을 다음 예제 코드를 통해 확인해보자.
#include <iostream>
using namespace std;
class First
{
private:
char * strOne;
public:
Fisrt(char *str)
{
strOne = new char[strlen[str] + 1];
}
~First()
{
cout << "~First" << endl;
delete[] strOne;
}
};
class Second: public First
{
private:
char * strTwo;
public:
Second(char *str1, char *str2): First(str1);
{
strTwo = new char[strlen[str2] + 1];
}
~Second()
{
cout << "~Second" << endl;
delete[] strTwo;
}
};
int main(void)
{
First *ptr = new Second("simple", "complex");
delete ptr;
return 0;
}
실행결과
~Frist
위에서 객체의 소멸을 First형 포인터로 명령하니, First 클래스의 소멸자만 호출되었다. 따라서 이러한 경우 메모리 누수가 발생하게 된다. 그러니 객체 소멸과정에서는 delete 연산자에 사용된 포인터 변수의 자료형에 관계 없이 모든 소멸자가 호출되어야 한다. 이릉 위해 다음과 같이 소멸자에 virtual 선언을 추가하면 된다.
virtual ~Fisrt()
{
cout << "~First" << endl;
delete[] strOne;
}
가상함수와 마찬가지로 소멸자도 상속의 계층구조상 맨 위에 존재하는 기초 클래스의 소멸자만 virtual로 선언하면 유도 클래스의 소멸자들도 모두 '가상 소멸자'로 선언이 된다. 그리고 가상 소멸자가 호출되면 상속의 계층구조상 맨 아래에 존재하는 유도 클래스의 소멸자가 대신 호출되면서, 기초 클래스의 소멸자가 순차적으로 호출된다.
포인터의 특성과 마찬가지로,
C++에서 AAA형 포인터 변수는 AAA 객체 또는 AAA를 직접 혹은 간접적으로 상속하는 모든 객체를 가리킬 수 있다(객체의 주소값을 저장할 수 있다).
참조자에도 같은 특성이 적용된다.
C++에서 AAA형 참조자는 AAA 객체 또는 AAA를 직접 혹은 간접적으로 상속하는 모든 객체를 참조할 수 있다(객체의 주소값을 저장할 수 있다).
그러므로
First형 참조자를 이용하면 First 클래스에 정의된 myFunc 함수가 호출되고, Second형 참조자를 이용하면 Second 클래스에 정의된 myFunc 함수가 호출되고, Third형 참조자를 이용하면 Third 클래스에 정의된 myFunc 함수가 호출된다.