[C++] 9.상속의이해(2)


상속의 적용-급여 관리 확장성 문제



이번 시간에는 이전 장에서 배웠던 상속이 도대체 어디에 쓰이면 유용한지 배워보겠습니다.

예제를 통해서 문제점을 파악하고, 점차 상속의 개념을 적용하여 해결해 보도록 하겠습니다.

KOO라는 회사가 설립되었다고 합시다.(제 성이 구이거든요.ㅋㅋㅋㅋ)

그리고 초창기에 직원들의 급여를 관리하는 시스템을 만들었습니다.

//클래스 직원(Employee)

class Employee
{
private:
	char name[10]; //직원들의 이름
	int salary; //매달 지불해야 하는 급여액
public:
	Employee(char *name,int money) :salary(money)
	{
		strcpy(this->name,name);
	}
	int GetPay() const //급여 반환 함수
	{
		return salary;
	}
	void ShowSalayInfo() cosnt //직원 정보 출력 함수
	{
		cout<<"name: "<<name<<endl;
		cout<<"salary: "<<GetPay()<<endl;
	}
};




//직원 클래스를 저장하고 관리하는 클래스

class EmployHandler
{
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();
	}
	~EmployeeHandler()
	{
		for(int i=0;i<empNum;i++)
			delete empList[i];
	}
};



앞서 정의한 Employee 클래스는 데이터적 성격이 강한 반면,

EmployeeHandler 클래스는 기능적인 성격이 강합니다.

새로운 직원정보를 등록하는 함수 AddEmplyee,

모든 직원의 이번 달 급여정보를 출력하는 함수 ShowAllSalaryInfo를 가진 클래스이죠~

이처럼 기능의 처리를 실제로 담당하는 클래스를 가리켜 ‘컨트롤 클래스’ 또는 ‘핸들러 클래스’라고 합니다.


int main(void)
{
	EmployeeHandler handler; //직원관리 컨트롤 클래스 객체 생성
	
	//직원 등록
	handler.AddEmployee(new Employee("KIM",1000));
	handler.AddEmployee(new Employee("KOO",2000));
	
	//이번 달에 지불해야 할 급여의 정보
	handler.ShowAllSalaryInfo();
	
	return 0;
}



잘 동작하는 것을 확인해볼 수 있습니다.

위의 작성된 프로그램은 전혀 문제가 되보이지 않아보입니다.

그런데, 회사 KOO가 번창하여 다음과 같이 직원의 형태가 다양해졌다고 합시다.


정규직(Permanent): 연봉제이며 매달의 급여가 정해져 있다.

고용직(Salse) : 정규직의 한 형태이며 기본급여에 인센티브가 더 주어진다.

임시직(Temporary) : 학생들을 대상으로 하는 임시 고용 형태이며,

정규직과 다르게 '시간당 급여 * 일한시간'으로 급여가 주어진다.



이거 문제가 심각해졌습니다.

클래스 Permanent, Sales, Temporary를 새로 정의를 해야하고,

자료형이 모두 다르기에 이를 관리하는 핸들러 클래스도 각각 다시 정의를 해야합니다.

처음부터 프로그램을 다시 만들다시피 해야할 것 같은데요..

전에 정의하였던 Employee를 이용하여 최대한 변경을 최소화하고 싶고,

정규직, 영업직, 임시직 모두 하나의 직원이기에 하나의 핸들러 클래스로 관리하고 싶은 마음 뿐입니다.

이 때, 사용되는 개념이 바로 상속입니다!!!

하지만, 전까지 배웠던 내용만으로는 아직 위의 문제를 해결할 수 없는데요..

상속 개념을 추가적으로 배워가면서 문제를 하나씩 해결해보겠습니다.



객체 포인터의 참조관계



전에 클래스를 기반으로도 포인터 변수를 선언할 수 있다고 하였습니다.

예를 들어 Person이라는 이름의 클래스가 정의되었다면,

Person 객체의 주소 값 저장을 위해서 다음과 같이 포인터 변수를 선언할 수 있습니다.

Person *ptr; //포인터 변수 선언

ptr=new Person(); //포인터 변수의 객체 참조



그런데 Person형 포인터는 Person 객체 뿐만 아니라,

Person을 직접 혹은 간접적으로 상속하는 모든 객체를 가리킬 수 있습니다.

예를 들어 다음과 같은 클래스가 있다고 합시다.


//학생
class Student : public Person //Person의 유도클래스 Student
{
	...
};

//근로학생
class PartTimeStudent : public Student //Student의 유도클래스
{
	...
};



이 때 Student는 Person을 상속하고, PartTimeStudent는 Student를 상속하기에,



PartTimeStudent는 Person을 간접적으로 상속합니다.

그러면 Person형 포인터 변수는 아래와 같이 Student 클래스와

PartTimeStudent 클래스 둘 다 가리킬 수 있습니다.


Person *ptr;

ptr=new Student(); //Person을 직접적으로 상속하는 유도클래스 가리킴

ptr=new PartTimeStudent(); //Person을 간접적으로 상속하는 유도 클래스 가리킴



어떻게 이런 일이 가능한걸까요??

이는 전에 배웠던 IS-A 관계로 설명이 됩니다.


학생(Student)는 사람(Person)이다.

근로학생(PartTimeStudnet)는 학생(Student)이다.

근로학생(PartTimeStudent)는 사람(Person)이다.

학생도 근로학생도 사람의 일종이기에, 사람을 가리키는 포인터로 둘을 가리킬 수 있는겁니다.



급여 확장성 문제 1차적 해결과 함수 오버라이딩



위에 설명한 포인터의 특성과 전 장에서 배운 상속의 개념으로 급여 확장성 문제를 조금 해결할 수 있습니다.

일단 새로 정의해야 할 클래스는 정규직(PermanentWorker), 영업직(SalesWorker),임시직(TemporaryWorker)입니다.

위의 세 클래스는 다음과 같이 IS-A 관계가 성립합니다.

정규직, 영업직, 임시직 모두 직원이다.

영업직은 정규직의 일종이다.



따라서, 다음과 같은 상속관계가 성립합니다.


정규직,임시직 클래스는 직원 클래스 (Employee)를 상속한다.

영업직 클래스는 정규직 클래스를 상속한다.



한 번 위와 같이 정의해볼까요?

먼저, 직원 클래스 Employee를 정의합니다.


//새롭게  정의된 직원 클래스 (임시직은 고정 월급이 없으므로 salary 삭제)
class Employee
{
private:
	char name[100];
public:
	Employee(char *name)
	{
		strcpy(this->name,name);
	}
	void ShowYourName() const
	{
		cout<<"name: "<<name<<endl;
	}
};



그 다음 고정 월급을 가진 정규직 클래스를 정의합니다.


//정규직 클래스

class PermanentWorker :public Employee
{
	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;
	}
};



그 다음은 고정된 월급이 없고 시간에 따라 급여를 받는 임시직을 정의하겠습니다.


//임시직 클래스

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;
	}
};



정규직과 다르게 실제 일을 한 시간을 기준으로 급여를 계산하도록 클래스가 정의되어 있습니다.

다음은 정규직의 일종이지만 인센티브를 더 받는 영업직을 정의하겠습니다.

이 클래스는 Employee가 아닌 PermanentWorker를 상속한다는 점을 기억해주세요!!


//영업직 클래스

class SalesWorker : public PermanentWorker
{
private:
	int salesResult; //월 판매실적
	double bonusRatio; //상여금 비율
public:
	SalesWorker(char* name,int money,double ratio)
		:PermanentWorker(name,money),bonusRatio(ratio){}
	void AddSalesResult(int value)
	{
		salesResult+=value;
	}
	int GetPay() const
	{
		return PermanentWorker::GetPay() //PermanentWorker의 GetPay 함수 호출
				+(int)(salesResult*bonusRatio);
	}
	void ShowSalaryInfo() const
	{
		ShowYourName();
		cout<<"salary: "<<GetPay()<<endl; //SalesWorker의 GetPay() 함수 호출
	} 
};	



영업직 클래스를 정의하면서 뭔가 이상한 점이 없었나요???

이상한 점을 발견하셔야 상속을 지금까지 잘 이해하고 계신겁니다~~

다음과 같은 이해하지 못하는 부분이 있습니다.

이미 PermanentWorker 클래스에 GetPay 함수와 ShowSalaryInfo 함수가 있어서,

유도클래스인 SalesWorker 클래스가 그것을 물려받았을텐데,

또 다시 같은 이름인 GetPay 함수와 ShowSalaryInfo 함수를 정의하고 있습니다.

심지어 ShowSalaryInfo 함수는 PermentWorker에 정의된 것과 내용까지 같습니다!!

이를 가리켜 ‘함수 오버라이딩’ 이라고 합니다.


함수 오버라이딩은 기초클래스와 유도클래스의 함수이름과 매개변수 형태도 같은 경우인데,

(매개변수 형태가 다르다면 함수 오버로딩이 됩니다.)

함수가 오버라이딩 되면, 오버라이딩 된 기초 클래스의 함수는 

오버라이딩 한 유도 클래스의 함수에 가려집니다.

그래서 위의 SalesWoker 클래스 내에서 GetPay 함수를 호출하면,

SalesWorker 클래스에 정의된 GetPay 함수가 호출됩니다.



만약, 유도클래스의 오버라이딩한 함수가 아닌 기초클래스의 오버라이딩된 함수를 호출하고 싶으면,

다음과 같이 ::을 이용합니다.


PermanentWorker::GetPay() //SalesWorker 클래스에도 정의되어 있죠!



SalesWorker 클래스에서 GetPay 함수는 PermanentWorker 클래스의 GetPay 함수와

내용이 달라서 다시 정의해서 오버라이딩했다고 치고,

왜 ShowSalaryInfo 함수는 내용이 같은데 오버라이딩 했을까요?


만약 오버라이딩 하지 않았다고 합시다.

SalesWorker 객체가 ShowSalaryInfo 함수를 호출하면 어떻게 될까요?

그러면 기초클래스인 PermanentWorker의 ShowSalaryInfo 함수가 호출되고,

PermanentWorker의 GetPay 함수를 호출하여 화면에 출력하게 됩니다.

하지만 분명 정규직과 영업직의 급여 형태는 다르죠!!!

SaleWorker의 GetPay 함수를 호출하여 화면에 출력해야 합니다.

그래서 ShowSalaryInfo를 SalesWorker에 다시 정의하여 오버라이딩한 것입니다.



오버라이딩 참 어려운 개념이죠??

중요한 개념이니 꼭 알아두시길 바랍니다.

어쨌든, 힘들게 정규직, 영업직, 임시직 클래스를 상속을 사용하여 정의했습니다.

그런데, 상속을 이용하여 정의한 것이 무엇이 좋은지 아직 느끼지 못하였습니다.

상속의 힘은 바로 핸들러 클래스에서 발휘됩니다!!!


//직원의 정보를 저장하고, 직원의 급여를 보여주는 핸들러 클래스
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();
	*/
	}
	~EmployeeHandler()
	{
		for(int i=0;i<empNum;i++)
			delete empList[i];
	}
};



사실상 ShowAllSalaryInfo 함수 안이 주석 처리 된 것 빼면,

초창기의 핸들러 클래스와 동일합니다.

아까 객체 포인터 변수는 그 객체와 그 객체를 직접 또는 간접적으로 상속하는 객체도

가리킬 수 있다고 한 것 기억하시나요?

정규직,영업직,임시직 모두 직원이기에 Employee를 직접 또는 간접적으로 상속하고 있습니다.

따라서 Employee형 포인터는

PermanentWorker,SalesWorker,TemporaryWorker 객체를 가리킬 수 있고,

핸들러 클래스 EmployeeHandler의 멤버변수 Employee* empList[50]로

모두 관리 할 수 있는 것입니다.

이것이 우리가 원하는 것이였죠!!


//예제 1

int main(void)
{
	//직원관리를 목적으로 설계된 컨트롤 클래스의 객체 생성
	EmployeeHandler handler;
	
	//정규직 등록
	handler.AddEmployee(new PermanentWoker("KOO",1000));
	
	//임시직 등록
	TemporaryWorker* alba=new TemporaryWoker("KIM",50);
	alba->AddWorker(5); //일한시간 5시간 등록
	handler.AddEmployee(alba);
   
	//영업직 등록
	SalesWorker* seller=new SalesWoker("LEE",1000,0.1);
	seller->AddSalesResult(7000); //영업실적 7000
	handler.AddEmployee(seller);
}



하지만, 아직 해결 못한 부분이 있습니다.

바로 아까 주석된 부분입니다.


class EmployeeHandler
{
	...
public:
	...
	void ShowAllSalaryInfo() const
	{
	/*
		for(int i=0; i<empNum; i++)
			empList[i]->ShowSalaryInfo();
	*/
	}
	...
};

//주석을 해제하면 컴파일 오류가 발생합니다.



이 문제를 해결하려면, 가상함수라는 개념을 알아야합니다.

갈길이 멀군요 ㅠㅠㅠ

이것만 해결되면 예제 1에서 다음과 같은 명령을 할 수 있습니다.


//모든 직원의 급여 정보를 출력하라!!!!

handler.ShowAllSalaryInfo();



가상함수(Virtual Function)



자, 급여 확장성 문제를 최종적으로 해결하기 위해서 가상함수라는 개념을 배워보도록 하겠습니다.

가상함수는 C++에서 매우 중요한 위치를 차지하는 문법입니다.

먼저, 다음의 코드를 보겠습니다.

int main(void)
{
	Simple* sim1=...;
	Simple* sim2=...;
}



위의 코드에서 객체 포인터 변수 sim1과 sim2가 가리키고 있는건 무엇일까요??

위에서 잘 배우셨다면,

답은 Simple 클래스 객체 또는 Simple 클래스를 상속하는 클래스의 객체라는 것을 알 수 있습니다.

이번에는 다음 두 클래스가 정의되어 있다고 합시다.


//base 클래스

class base
{
public:
	void basefunc(){cout<<"base function"<<endl;}
};

//base 클래스를 상속하는 derived 클래스

clas derived : public base
{
public:
	void derivedfuc(){cout<<"derived function"<<endl;}
};



그 다음 main 함수에 다음의 문장을 입력해보겠습니다.


int main(void)
{
	base* bptr=new derived(); //컴파일 OK (이미 배운 내용)
	bptr->derivedfuc(); //컴파일 error!
}



아니, 이럴수가! base형 포인터로 derived 객체를 가리키는 건 좋았는데..

그 포인터로 derived 객체의 멤버 변수를 호출할 수가 없습니다.

그 이유는 이렇습니다.


C++ 컴파일러는 포인터 연산의 가능성 여부를 판단할 때, 포인터의 자료형을 기준으로 판단하지,

실제 가리키는 객체의 자료형을 기준으로 판단하지 않습니다.



따라서 derived 클래스를 가리키는 base형 포인터 bptr은

실제로 자신이 가리키는 객체가 derived 클래스라는 것을 기억하지 않습니다.

따라서, bptr은 base 클래스에서 정의된 함수가 아닌

derived에서 정의된 함수를 호출할 수 없는 것입니다.

마찬가지 이후로 다음 문장들도 컴파일 에러를 일으킵니다.


int main(void)
{
	base* bptr=new derived(); //컴파일 OK
	derived* dptr=bptr; //컴파일 error
}



derived 형 포인터 dptr은 bptr을 보고 base형 객체일 수도 있다고 생각합니다.

그래서 컴파일 오류를 발생시킵니다.

반면에, 다음 문장은 컴파일이 됩니다.


int main(void)
{
	derived* dptr=new derived(); 
	base* bptr=dptr; //컴파일 OK
}



bptr은 dptr의 자료형을 보고 derived 클래스 객체이므로

base를 직접 혹은 간접적으로 상속하는 객체라고 판단합니다.

따라서 컴파일이 제대로 됩니다.



이해를 돕기 위하여 예제를 하나 더 보겠습니다.


class First
{
public:
	void FirstFunc(){cout<<"First"<<endl;}
};

class Second : public First
{
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; //상속의 관계이므로 성립
	
	tptr->FirstFunc(); //컴파일OK
	tptr->SecondFunc(); //컴파일OK
	tptr->ThirdFunc(); //컴파일OK
	
	sptr->FirstFunc(); //컴파일OK
	sptr->SecondFunc(); //컴파일OK
	sptr->ThirdFunc(); //컴파일error
	
	fptr->FirstFunc(); //컴파일OK
	fptr->SecondFunc(); //컴파일error
	fptr->ThirdFunc(); //컴파일error
}



결론적으로 포인터 형에 해당하는 클래스에 정의된 멤버에만 접근이 가능한 것입니다.

이번에는 First,Second,Third 함수의 이름을 모두 같게 하겠습니다.


class First
{
public:
	void Func(){cout<<"First"<<endl;}
};

class Second : public First
{
public:
	void Func(){cout<<"Second"<<endl;}
};

class Third : public Second
{
public:
	void Func(){cout<<"Third"<<endl;}
};




int main(void)
{
	Third* tptr=new Third();
	Second* sptr=tptr;
	First* fptr=sptr; //상속의 관계이므로 성립

	fptr->Func(); 
	sptr->Func(); 
	tptr->Func(); 
}

//출력결과

First
Second
Third



전에 배운 함수 오버라이딩 때문에 그렇습니다!!

그런데, 함수 오버라이딩이 가리키는 대상이 아닌 포인터의 자료형에 따라 이루어지는 것을 알 수 있습니다.

저희가 원하는 것은 포인터 자신의 자료형이 아닌

가리키는 대상의 자료형에 따라 함수오버라이딩 하고 싶은건데요..

이 때 사용되는 개념이 ‘가상함수’입니다.


//가상함수

class First
{
public:
	virtual void Func(){...}
};



First 클래스의 Func에 virtual 선언을 하면 이 함수를 오버라이딩 하는

Second,Third 클래스의 Func에도 자동적으로 virtual 선언이 됩니다.

하지만 virtual를 일일이 붙여주어도 상관은 없습니다.

자, virtual 키워드를 세 개의 클래스에 다시 붙여보겠습니다.


//키워드 virtual 추가


class First
{
public:
	virtual void Func(){cout<<"First"<<endl;}
};

class Second : public First
{
public:
	virtual void Func(){cout<<"Second"<<endl;}
};

class Third : public Second
{
public:
	virtual void Func(){cout<<"Third"<<endl;}
};




int main(void)
{
	Third* tptr=new Third();
	Second* sptr=tptr;
	First* fptr=sptr; //상속의 관계이므로 성립

	fptr->Func(); 
	sptr->Func(); 
	tptr->Func(); 
}

//출력결과

Third
Third
Third



위의 결과를 보면 우리가 원한 것처럼 포인터의 자료형이 아닌,

가리키는 대상의 자료형에 따라 함수가 오버라이딩 된 것을 볼 수 있습니다.

즉, virtual 키워드를 쓰면 가리키는 포인터를 이용하여 가리키는 대상의 함수를 호출할 수 있습니다.

이로써 급여 확장성 문제를 완전히 해결할 수 있게 되었습니다.



급여 확장성 문제 완전한 해결



자, 지체하지 않고 바로 급여 확장성 문제를 해결해 보겠습니다.

문제가 되었던 부분은 바로 이 주석 부분입니다.

class EmployeeHandler
{
	...
public:
	...
	void ShowAllSalaryInfo() const
	{
	/*
		for(int i=0; i<empNum; i++)
			empList[i]->ShowSalaryInfo();
	*/
	}
	...
};



왜 문제가 되냐면, empList형 포인터는 empList형에 정의된 함수만 호출할 수 있기 때문입니다.

따라서 Employee 클래스를 다음과 같이 수정해줍니다.


class Employee
{
	...
public:
	...
	virtual int GetPay() const //추가
	{
		return 0;
	} 
	virtual void ShowSalaryInfo() const{} //추가
};



Employee의 GetPay, ShowSalaryInfo에만 virtual를 붙여도

이를 오버라이딩 하는 함수에 자동으로 붙습니다.

함수 오버라이딩이 되야하기 때문에,

Employee에도 GetPay와 ShowSalaryInfo 함수를 추가하였습니다.

그리고, Employee는 사실상 기초 클래스로서만 의미를 가질 뿐, 객체 생성을 목적으로 하지 않습니다.

이러한 클래스를 추상클래스라고 합니다.

또, 추상클래스는 순수 가상함수를 사용하여 객체생성을 못하게 막는 것이 좋습니다.


class Employee
{
	...
public:
	...
	virtual int GetPay() const=0; //순수 가상함수
	virtual void ShowSalaryInfo() const=0; //순수 가상함수
};



자, 이렇게 Employee 클래스만 수정하면, 아까 문제가 되었던 주석을 풀어도 컴파일이 됩니다.

실행결과는 생략할게요~~~!


아, 그리고 지금까지 포인터에 대해 얘기했는데, 참조자에서도 똑같이 적용가능합니다!!!



자, 이렇게 상속에 대해 배워보았는데요~

이제 상속을 하는 이유를 알 수 있습니다.

상속을 통해 연관된 일련의 클래스들에 공통적인 규약을 정의할 수 있고, 관리할 수 있기 때문입니다.

정말 중요한 개념이니 꼭 알고 가시길 바랍니다.

이번 장은 역대급으로 길었네요.. ㅜㅜ (힘들어 죽는줄;)

봐주셔서 감사합니다~~~

다음 장에서 뵙겠습니다