열아홉번째 열혈강의

이어서 가상함수(Virtual Function) 에 대해 알아보도록 하자.
이 가상함수라고 하는 것은 C++에서 매우 중요한 위치를 차지하는 문법이다. 앞선 포스팅에서 확장성 문제에 대한 해결방안 이기도 하다.
먼저 코드를 하나 보자.
int main(){
Simple* sim1 = new ...;
Simple* sim2 = new ...;
}
이 코드가 정상적으로 컴파일 됬다고 할 때, 위 두 포인터 변수가 가리키는 객체의 자료형은 각각 무엇일까?
sim1, sim2가 가리키는 객체는 Simple 클래스, 혹은 Simple 클래스를 상속하는 클래스의 객체다.
위와 같은 답변은 지금까지 해온 과정을 아주 잘 이해한 것이다. 또 실제 컴파일러가 포인터 변수를 바라보는 방식이기도 하다.
다음 예제를 같이 보도록 하자.
class Base{
public:
void baseFunc() const {cout << "Base Function" << endl;}
};
class Derived : public Base{
public:
void DerivedFunc() const {cout << "Derived Function" << endl;}
};
int main(){
Base* bptr = new Derived(); //컴파일 ok
bptr->DerivedFunc(); //컴파일 에러
}
여기서 컴파일 에러가 나는 부분에 의문이 생긴다.
실제로 가리키는 대상이 Derived 객체인데 왜 컴파일 에러가 날까?
아마 조금 혼란스러울 수 있는데, C++ 컴파일러는 포인터 연산의 가능성 여부를 판단할 때, 포인터의 자료형을 기준으로 판단한다. (실제 가리키는 객체의 자료형을 기준으로 정하지 않는다.) 따라서 다음 코드도 오류가 나온다.
int main(){
Base* bptr = new Derived(); //컴파일 ok
Derived* dptr = bptr; //컴파일 에러
...
}
포인터 bptr의 포인터 형만을 가지고 대입의 가능성을 판단하기 때문이다.
Derived 클래스는 Base 클래스의 유도 클래스이므로 Base 클래스의 포인터 변수로 Derived 객체의 참조가 가능하고 문제 없이 컴파일이 가능하다. 그러나 bptr은 Base형 포인터이므로 bptr이 가리키는 대상은 Base 객체일 수도 있는 것이다. 그럴 경우, Derived* dptr = bptr; 은 성립되지 않으므로 컴파일 에러를 발생시킨다.
int main(){
Derived* dptr = new Derived(); // 컴파일 ok
Base* bptr = dptr; // 컴파일 ok
}
위 문장은 둘다 오류없이 돌아가게 된다.
dptr이 가리키는 객체는 Base 클래스를 직접 혹은 간접상속하는 객체가 되므로 Base형 포인터 변수로도 참조가 가능하다.
class First{
public:
void FirstFunc() const { cout << "FirstFunc" << endl; }
};
class Second : public First{
public:
void SecondFunc() const { cout << "SecondFunc" << endl; }
};
class Third : public Second{
public:
void ThirdFunc() const { cout << "ThirdFunc" << endl; }
};
int main(){
Third* tptr = new Third();
Second* sptr = tptr;
First* fptr = sptr;
...
}
위 내용은 복습차원에서 충분히 이해할 수 있다.
int main(){
Third* tptr = new Third();
Second* sptr = tptr;
First* fptr = sptr;
tptr->FirstFunc();
tptr->SecondFunc();
tptr->ThirdFunc();
sptr->FirstFunc();
sptr->SecondFunc();
//sptr->ThirdFunc();
fptr->FirstFunc();
//tptr->SecondFunc();
//tptr->ThirdFunc();
...
}
여기서 주석으로 처리된 곳은 컴파일이 안된다.
결론적으로 포인터 형에 해당하는 클래스에 정의된 멤버에만 접근이 가능 하다.
함수 오버라이딩
이제 진짜 가상함수에 대한 이야기를 시작해보자.
class First{
public:
void MyFunc() const { cout << "FirstFunc" << endl; }
};
class Second : public First{
public:
void MyFunc() const { cout << "SecondFunc" << endl; }
};
class Third : public Second{
public:
void MyFunc() const { cout << "ThirdFunc" << endl; }
};
int main(){
Third* tptr = new Third();
Second* sptr = tptr;
First* fptr = sptr;
fptr->MyFunc();
sptr->MyFunc();
tptr->MyFunc();
delete tptr;
return 0;
}
출력을 보면
FirstFunc
SecondFunc
ThirdFunc
와 같이 나오는 걸 볼 수 있고, 각 형태에 대한 포인터 변수에 따라 MyFunc이 호출됨을 알 수 있다.
그러나 좀더 넓게 생각해 볼 필요가 있다.
fptr->MyFunc();
위 문장을 컴파일러는 fptr이 First 포인터이니, 해당 클래스에 정의된 MyFunc을 호출하자 라고 생각하게 된다.
sptr->MyFunc();
tptr->MyFunc();
위 문장들은 각 해당하는 포인터가 무슨 형인지 알고 있고, 함수 오버라이딩 관계로 존재함을 알고 있으므로 오버라이딩을 한 각 함수를 호출하게 된다.
가상함수(Virtual Function)
위 예제들을 통해 다음과 같이 생각할 수 있다.
함수를 오버라이딩 했다함은 해당 객체에서 호출되어야 하는 함수를 바꾼다는 의미인데, 포인터 변수의 자료형에 따라서 호출되는 함수의 종류가 달라지는 건 오류가 있어 보인다.
앞서 급여체계에 있어서도, GetPay 함수를 오버라이딩 헀었는데, 포인터의 자료형을 이유로 PermanentWorker 클래스의 GetPay 함수가 대신 호출되어서 급여가 결정된다면, 문제가 생겨버린다.
따라서 C++은 이런 상황이 생기지 않도록 가상함수 라는 것을 제공하고 있다. 이 가상함수는 C++ 개념이라기보다 객체지향의 개념이다. 따라서 JAVA나 C#과 같은 객체지향 언어에도 이런 형태의 문법이 존재한다.
그럼 한번 사용해보자.
class First{
public:
virtual void MyFunc() { cout << "FirstFunc" << endl; }
};
이렇게 virtual 이라는 키워드 선언을 통해 이뤄진다. 이렇게 선언이 되면 이 함수를 오버라이딩 하는 함수도 가상함수가 된다. 따라서 위와 같이 First 클래스의 MyFunc 함수가 virtual로 선언되면, 이를 오버라이딩 하는 Second 클래스, Third 클래스의 MyFunc 함수도 가상함수가 된다. 그럼 다음 예제를 보자.
class First{
public:
virtual void MyFunc() const { cout << "FirstFunc" << endl; }
};
class Second : public First{
public:
virtual void MyFunc() const { cout << "SecondFunc" << endl; }
};
class Third : public Second{
public:
virtual void MyFunc() const { cout << "ThirdFunc" << endl; }
};
int main(){
Third* tptr = new Third();
Second* sptr = tptr;
First* fptr = sptr;
fptr->MyFunc();
sptr->MyFunc();
tptr->MyFunc();
delete tptr;
return 0;
}
출력은 다음과 같다.
ThirdFunc
ThirdFunc
ThirdFunc
위 코드에서 일단 Second와 Third 클래스 내에도 virtual을 선언했는데, 사실 안해도 되지만 해주는 것이 코드 알아보기에 더욱 좋다.
위 실행결과를 보듯, 가상함수로 선언하면 해당 함수호출 시, 포인터의 자료형을 기반으로 호출대상을 결정하지 않고, 포인터 변수가 실제로 가리키는 객체를 참조하여 호출의 대상을 결정 한다.
그럼 이전부터 가지고 오던, 급여관리 확장성 문제를 해결해 보자.
class EmployeeHandler{
private:
Employee* empList[50];
int empNum;
public:
EmployeeHandler() : empNum(0){}
void AddEmployee(Employee* emp){
empList[empNum++] = emp;
}
void ShowAllSalaryInfo() const{
// for(int i = 0; i < empNum; i++){
// empList[i]->ShowSalaryInfo();
// }
}
void ShowTotalSalary() const{
int sum = 0;
// for(int i = 0; i < empNum; i++){
// sum += empList[i]->GetPay();
// }
cout << "salary sum : " << sum << endl;
}
~EmployeeHandler(){
for(int i = 0; i < empNum; i++){
delete empList[i];
}
}
};
여기서 주석으로 처리했던 부분이 문제가 되었었다.
보면, 배열을 구성하는 포인터 변수가 Employee형 포인터 변수이므로, Employee 클래스의 멤버가 아닌 GetPay 함수와 ShowSalaryInfo 함수의 호출부분에서 컴파일 에러가 발생하므로 주석을 처리했던 것이다.
그럼 어떻게 해결할 수 있을까? 먼저 Employee형 포인터 변수를 대상으로 이 두함수를 호출할 수 있게 해야 한다. 또한, Employee 클래스에 GetPay 함수와 ShowSalaryInfo 함수를 추가로 정의하고, 가상함수로 선언한다.
#include <iostream>
using namespace std;
class Employee{
private:
char name[100];
public:
Employee(char* name){
strcpy(this->name, name);
}
void ShowYourName() const {
cout << "name : " << name << endl;
}
virtual int GetPay() const {
return 0;
}
virtual void ShowSalaryInfo() const{
}
};
class PermanentWorker : public Employee{
private:
int salary;
public:
PermanentWorker(char* name, int money) : Employee(name), salary(money){}
int GetPay() const{
return salary;
}
void ShowSalaryInfo() const{
ShowYourName();
cout << "salary : " << GetPay() << endl << endl;
}
};
class TemporaryWorker : public Employee{
private:
int workTime;
int payPerHour;
public:
TemporaryWorker(char* name, int pay) : Employee(name), workTime(0), payPerHour(pay){}
void AddWorkTime(int time){
workTime += time;
}
int GetPay() const{
return workTime * payPerHour;
}
void ShowSalaryInfo() const{
ShowYourName();
cout << "salary : " << GetPay() << endl << endl;
}
};
class SalesWorker : public PermanentWorker{
private:
int salesResult;
double bonusRatio;
public:
SalesWorker(char* name, int money, double ratio) : PermanentWorker(name, money), salesResult(0), bonusRatio(ratio){}
void AddSalesResult(int value){
salesResult += value;
}
int GetPay() const{
return PermanentWorker::GetPay() + (int)(salesResult * bonusRatio);
}
void ShowSalaryInfo() const{
ShowYourName();
cout << "salary : " << GetPay() << endl << endl;
}
};
class EmployeeHandler{
private:
Employee* empList[50];
int empNum;
public:
EmployeeHandler() : empNum(0){}
void AddEmployee(Employee* emp){
empList[empNum++] = emp;
}
void ShowAllSalaryInfo() const{
for(int i = 0; i < empNum; i++){
empList[i]->ShowSalaryInfo();
}
}
void ShowTotalSalary() const{
int sum = 0;
for(int i = 0; i < empNum; i++){
sum += empList[i]->GetPay();
}
cout << "salary sum : " << sum << endl;
}
~EmployeeHandler(){
for(int i = 0; i < empNum; i++){
delete empList[i];
}
}
};
int main(void){
// 직원관리를 목적으로 설계된 컨트롤 클래스의 객체생
EmployeeHandler handler;
// 정규직 등록
handler.AddEmployee(new PermanentWorker("KIM", 1000));
handler.AddEmployee(new PermanentWorker("LEE", 1500));
// 임시직 등록
TemporaryWorker* alba = new TemporaryWorker("Jung", 700);
alba->AddWorkTime(5);
handler.AddEmployee(alba);
// 영업직 등록
SalesWorker* seller = new SalesWorker("Hong", 1000, 0.1);
seller->AddSalesResult(7000);
handler.AddEmployee(seller);
// 이번 달 지불 급여 정보
handler.ShowAllSalaryInfo();
// 이번달 지불 급여 총합
handler.ShowTotalSalary();
return 0;
}
그럼 아주 알맞은 출력이 나오게 된다.
이제 아주 중요한 이해 과정을 진행 해보자.
상속을 하는 이유는 무엇인가?
와닿지는 않을 지 모르지만 다음과 같이 말할 수 있다.
상속을 통해 연관된 일련의 클래스에 대해 공통적인 규약을 정의할 수 있다.
위 급여체계 예제를 통해 풀어써보면
상속을 통해 연관된 일련의 클래스 PermanentWorker, TemporaryWorker, SalesWorker에 공통적인 규약을 정의할 수 있다.
그것은 바로 Employee 클래스가 될 것이다. 달리 말하면, 적용하고픈 공통규약을 모아서 Employee 클래스를 정의하였고, 이로 인해 Employee 클래스를 상속하는 모든 클래스의 객체는 Employee 객체로 바라볼 수 있게 된 것이다.
실제로 EmployeeHandler 클래스는 저장되는 모든 객체를 Employee 객체로 바라본다. 때문에 새로운 클래스가 추가되어도 EmployeeHandler 클래스는 변경할 필요가 없다. 물론 객체의 자료형에 따라서 행동의 방식에는 차이가 있어야 한다.(호출되는 함수에는 차이가 있어야 한다) 그러나 이 문제역시 함수 오버라이딩과 가상함수 개념으로 해결이 된다.
다음 포스팅에 순수 가상함수와 추상클래스에 대해 다루며 코드 리펙토링을 진행하도록 하겠다.