티스토리 뷰
주의 사항!
- 이 글은 제가 직접 공부하는 중에 작성되고 있습니다.
- 따라서 제가 이해하는 그대로의 내용이 포함됩니다.
- 따라서 이 글은 사실과는 다른 내용이 포함될 수 있습니다.
이번에 배우는 가상 함수는 C++에서 매우 중요한 위치를 차지하는 문법입니다. 뿐만 아니라, '오렌지 미디어 급여관리 확장성 문제'를 완전히 해결하는데 필요한 도구이기도 합니다.
객체 포인터의 특성
앞서 객체 포인터에 대해 배우면서, 객체 포인터는 해당 객체의 클래스를 직접 혹은 간접적으로 상속받는 다른 객체들을 가리킬 수 있다고 배웠습니다. 하지만 그러면서도 해당 객체 포인터는 유도 클래스의 멤버들에 접근하지는 못했습니다.
다시 예를 들어보겠습니다. Person 클래스와 Student 클래스가 정의되었습니다. Student클래스는 Person 클래스를 상속받고 있습니다. 즉, Person 클래스는 기초 클래스, Student 클래스는 유도 클래스가 됩니다. 그러면 다음과 같이 Person 객체 포인터에 Student 객체의 주소를 저장하는 것이 가능합니다.
Person* ptr;
ptr = new Student;
하지만 위의 포인터 변수인 ptr이 Student객체를 가리키고 있다고 해서, Student 객체만이 가지고 있는(Student 객체는 Person의 멤버도 가지고 있으므로 구분함) 멤버에 접근하는 것은 불가능하다고 했습니다. 해당 사항을 시도하게 되면 컴파일 에러가 발생하게 됩니다. 아래는 잘못된 접근의 예입니다.
class Person
{
public:
void Sleep() {cout << "Person sleep" << endl;}
};
class Student : public Person
{
public:
void Study() {cout << "Student study" << endl;}
};
int main(void)
{
Person* ptr = new Student;
ptr->study(); //컴파일 에러 발생
return 0;
}
해당 에러가 발생하는 이유는 다음과 같습니다. ptr 포인터는 Person 형 포인터입니다. 비록 해당 포인터가 Person을 상속하고 있는 Student를 가리키고 있지만, 이 포인터를 사용할 때는 오로지 이 포인터의 자료형만 보고 판단하게 됩니다. 즉, 해당 포인터는 비록 Student 객체를 가리키고 있지만 자료형은 Person이므로, 해당 포인터를 통해 접근할 수 있는 멤버들은 Person의 멤버들로 제한됩니다.
같은 이유로 다음과 같은 코드도 컴파일 에러를 발생시킵니다.
Person* ptr1 = new Student;
Student* ptr2 = ptr1; //컴파일 에러 발생
ptr1은 비록 Student 객체를 가리키지만 Person 자료형을 가지고 있습니다. 따라서 Student형 포인터인 ptr2에 Person형 포인터인 ptr1의 데이터를 대입시키는 연산이 불가능하게 됩니다.
앞서 다음의 예제를 보았습니다.
#include <iostream>
#include <cstring>
using namespace std;
class Employee
{
private:
char name[40];
public:
Employee(const char* name)
{
strcpy(this->name, name);
}
void ShowYourName() const
{
cout << "name : " << name << endl;
}
};
class PermanentWorker : public Employee
{
private:
int salary;
public:
PermanentWorker(const char* name, int money)
:Employee(name), salary(money) {}
int GetPay() const
{
return salary;
}
void ShowSalaryInfo() const
{
ShowYourName();
cout << "salary : " << salary << endl << endl;
}
};
class EmployeeHandler
{
private:
Employee* empList[10];
int empNum;
public:
EmployeeHandler() : empNum(0) {}
void AddEmployee(Employee* emp)
{
empList[empNum++] = emp;
}
void ShowAllSalaryInfo() const
{
/*
for (int i = 0l 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));
handler.AddEmployee(new PermanentWorker("KOEY", 4000));
//이번 달에 지불해야 할 급여의 정보
handler.ShowAllSalaryInfo();
//이번 달에 지불해야할 급여의 총합
handler.ShowTotalSalary();
return 0;
}
/*
실행결과
salary sum: 0
*/
객체 포인터의 특성을 이용해 '오렌지 미디어 급여관리 확장성 문제'를 해결하기 위해 EmployeeHandler라는 핸들러 클래스, 고용인 정보를 저장하는 Employee 클래스, 정규직 정보를 저장하는 PermanentWorker 클래스를 정의했습니다. PermanentWorker 클래스는 Employee 클래스를 상속받습니다.
위 예제를 보면, EmployeeHandler 클래스의 ShowAllSalaryInfo 함수와 ShowTotalSalary 함수의 내부에 주석 처리된 부분이 있습니다. 해당 부분이 주석 처리된 이유는 Employee형 객체 포인터를 통해 PermanentWorker의 멤버 함수를 호출하려 했기 때문입니다. 지금까지 말해왔듯 해당 연산은 컴파일 에러를 발생시킵니다.
가상 함수가 필요한 이유
그런데 정말로 위 연산이 불가능하다면 '오렌지 미디어 급여관리 확장성 문제'를 해결할 다른 방법을 찾기가 어려워 보입니다. 지금부터 배울 '가상 함수'는 위 주석 처리된 부분의 문제를 해결하는 데 해답이 됩니다.
먼저 다음의 예제를 보겠습니다.
#include <iostream>
using namespace std;
class First
{
public:
void MyFunc() {cout << "Firstfunc" << endl;}
};
class Second : public First
{
public:
void MyFunc() {cout << "Secondfunc" << endl;}
};
class third : public Second
{
public:
void MyFunc() {cout << "Thirdfunc" << endl;}
};
int main(void)
{
Third* tptr = new Third();
Secont* sptr = tptr;
First* fptr = sptr;
fptr->MyFunc();
sptr->MyFunc();
tptr->MyFunc();
delete tptr;
return 0;
}
/*
실행결과
Firstfunc
Secondfunc
Thirdfunc
*/
위 예제에는 First, Second, Thrid 세 개의 클래스가 정의되어 있습니다. 그리고 각 클래스에는 MyFunc 함수가 오버 라이딩되어 있습니다. main 함수의 실행 결과를 보면 다음과 같이 생각할 수 있습니다.
'객체 포인터가 가리키는 객체와는 관계없이, 객체 포인터의 자료형에 따라서 해당 클래스의 MyFunc 함수가 호출되는구나'
그러나 이는 조금 잘못된 생각입니다. 먼저, fptr 포인터를 통해 MyFunc 함수를 호출할 때, 컴파일러는 다음과 같이 생각합니다.
'fptr의 자료형이 First이므로 이 포인터로 호출할 수 있는 MyFunc 함수는 First 클래스의 것이 전부구나. 이것을 호출하면 되겠다.'
sptr 포인터를 통해 MyFunc 함수를 호출할 때는 다음과 같이 생각합니다.
'sptr의 자료형이 Second이므로 이 포인터로 호출할 수 있는 MyFunc 함수는 First 클래스의 것과 Second 클래스의 것 두 개가 있구나. 그럼 가장 깊이 오버 라이딩된 Second의 것을 호출해야겠다.'
tptr에 대해서는 더 설명하지 않아도 이제 눈치챘을 것입니다. tptr 포인터를 통해서는 First, Second, Third 클래스 모두의 MyFunc 함수를 호출할 수 있고, 그 중 가장 깊이 오버 라이딩된 Third의 것을 호출합니다.
그런데, 함수를 오버 라이딩했다는 것은 해당 '객체'에 따라서 호출되어야 하는 함수를 바꾼다는 의미인데, 포인터 변수의 '자료형(클래스)'에 따라서 호출되는 함수가 달라진다는 것은 문제가 있어 보입니다. 다행히도 이는 '가상 함수'를 통해 해결할 수 있습니다.
가상 함수의 문법적 이해
First 클래스의 MyFunc 함수를 가상 함수로 선언해보겠습니다.
class First
{
public:
virtual void MyFunc() {cout << "FirstFunc" << endl;}
};
가상 함수의 선언은 virtual 키워드를 사용해서 선언할 수 있습니다. 그리고 어떤 멤버 함수를 가상 함수로 선언하면, 그 함수를 오버 라이딩하고 있는 다른 함수들도 자동으로 가상 함수로 선언됩니다.
다음 예제를 통해 가상 함수의 특성을 알아보겠습니다.
#include <iostream>
using namespace std;
class First
{
public:
virtual void MyFunc() { cout << "Firstfunc" << endl; } //가상 함수 선언
};
class Second : public First
{
public:
void MyFunc() { cout << "Secondfunc" << endl; } //자동으로 가상 함수 선언
};
class Third : public Second
{
public:
void MyFunc() { cout << "Thirdfunc" << endl; } //자동으로 가상 함수 선언
};
int main(void)
{
Third* tptr = new Third();
Second* sptr = tptr;
First* fptr = sptr;
fptr->MyFunc();
sptr->MyFunc();
tptr->MyFunc();
delete tptr;
return 0;
}
/*
실행결과
Thirdfunc
Thirdfunc
Thirdfunc
*/
객체 포인터를 통해 가상 함수를 호출하면, 포인터의 '자료형'에 따라 함수를 호출하는 것이 아니라, 포인터가 가리키고 있는 '객체'에 따라서 호출할 함수를 선택하게 됩니다. 따라서 위 예제의 fptr, sptr, tptr은 모두 Third 객체를 가리키고 있으므로 Third 클래스의 MyFunc 함수를 호출하였습니다.
이제 '오렌지 미디어 급여 관리 확장성 문제'를 완전히 해결할 수 있게 되었습니다.
#pragma warning(disable: 4996)
#include <iostream>
#include <cstring>
using namespace std;
class Employee
{
private:
char name[40];
public:
Employee(const 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(const char* name, int money)
:Employee(name), salary(money) {}
int GetPay() const
{
return salary;
}
void ShowSalaryInfo() const
{
ShowYourName();
cout << "salary : " << salary << endl << endl;
}
};
class EmployeeHandler
{
private:
Employee* empList[10];
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));
handler.AddEmployee(new PermanentWorker("KOEY", 4000));
//이번 달에 지불해야 할 급여의 정보
handler.ShowAllSalaryInfo();
//이번 달에 지불해야할 급여의 총합
handler.ShowTotalSalary();
return 0;
}
/*
실행결과
name : KIM
salary : 1000
name : LEE
salary : 1500
name : KOEY
salary : 4000
salary sum: 6500
*/
PermanentWorker의 GetPay 함수와 ShowSalaryInfo 함수를 Employee 클래스에도 가상 함수로 선언합니다. 그렇게 되면 해당 두 함수에서 접근하는 salary 변수는 Employee에서는 접근할 수 없기 때문에 에러가 발생합니다. 따라서 Employee에 선언한 두 가상 함수의 내용을 모두 비웁니다.
어차피 이 두 함수는 PermanentWorker 만의 것이고 PermanentWorker를 통해서만 호출될 것이기 때문에 Employee에 선언된 함수를 호출하는 일은 없습니다. 다만 PermanentWorker의 가상 함수를 호출하기 위해 필요한 형식 상의 가상 함수일 뿐입니다.
순수 가상 함수와 추상 클래스
아직 위 예제의 Employee 클래스는 조금 더 개선할 여지가 남아 있습니다. Employee 클래스는 PermanentWorker 클래스에 상속하기 위한 기초 클래스로서의 의미만 있을 뿐, 이 클래스를 통한 객체의 생성을 목적으로 정의된 클래스는 아닙니다. 따라서 다음과 같은 코드가 작성되어 있다면 이는 프로그래머의 실수가 틀림없습니다.
Employee* emp = new Employee("KOEY");
물론 위 코드는 컴파일 에러를 발생시키지 않습니다. Employee 도 엄연한 클래스이며, KOEY를 저장할 name도 멤버 변수로 가지고 있기 때문입니다. 그래서 프로그래머가 이런 실수를 저지르면 이를 발견하기가 힘듭니다. 따라서 이런 실수를 확실히 방지할 수 있는 대책이 있으면 좋을 것 같습니다.
위에서 지적한 실수는 '순수 가상 함수'를 통해서 방지할 수 있습니다. 순수 가상 함수는 함수의 몸체가 없는, 정말 서류상 존재할 뿐인 가상 함수를 의미합니다. 순수 가상 함수의 선언은 다음과 같이 합니다.
class Employee
{
private:
char name[40];
public:
Employee(const char* name)
{
strcpy(this->name, name);
}
void ShowYourName() const
{
cout << "name : " << name << endl;
}
virtual int GetPay() const = 0; //순수 가상함수의 선언
virtual void ShowSalaryInfo() const = 0; //순수 가상함수의 선언
};
가상 함수의 몸체{ }를 없애고 0을 대입합니다. 하지만 이는 정말로 해당 함수에 0을 대입하겠다는 의미는 아니고, 컴파일러에 이 함수는 순수 가상 함수임을 알려주는 의미를 가집니다. 따라서 컴파일할 때 해당 함수의 몸체가 정의되어 있지 않아도 컴파일 오류를 발생시키지 않습니다.
하지만 순수 가상 함수가 선언된 Employee 클래스는 완전하지 않은, 서류상으로 존재할 뿐인 클래스가 됩니다. 이처럼 객체의 생성을 목적으로 하지 않는, 적어도 하나 이상의 순수 가상 함수를 가지고 있는 클래스를 '추상 클래스'라고 합니다.
그리고 추상 클래스를 이용해 다음과 같이 객체를 생성하려고 하면 컴파일 에러를 발생시키게 됩니다.
Employee* emp = new Employee("KOEY"); //컴파일 에러 발생
이처럼 순수 가상 함수를 사용함으로써 추상 클래스를 만들어냈고, 추상 클래스로 객체를 생성하는 것을 방지하는 대책도 마련할 수 있었습니다. 그런데 순수 가상 함수의 사용으로 얻을 수 있는 이점은 하나가 더 있습니다. 바로 Employee에 정의된 가상 함수 GetPay와 ShowSalaryInfo 함수는 오로지 PermanentWorker의 가상 함수를 사용하기 위해 존재할 뿐, 실제로 호출되는 함수는 아니었는데, 이를 보다 명확하게 명시하는 효과도 얻을 수 있습니다.
지금까지 설명한 가상 함수의 호출 관계에서 보인 특성을 '다형성'이라고 합니다. 그리고 이는 객체지향을 설명하는 데 있어서 매우 중요한 요소이기도합니다.
다형성이란 '동질이상'을 의미합니다. 즉, 다음의 의미를 가지고 있습니다.
- 모습은 같은데 형태는 다르다.
- 문장은 같은데 결과는 다르다.
'공부 일지 > CPP 공부 일지' 카테고리의 다른 글
C++ | 멤버 함수와 가상 함수의 동작 원리 (0) | 2021.08.03 |
---|---|
C++ | 가상 소멸자와 참조자의 참조 가능성 (0) | 2021.08.03 |
C++ | 객체 포인터와 다형성 (0) | 2021.08.02 |
C++ | 상속 (0) | 2021.08.02 |
C++ | static과 클래스 (0) | 2021.08.02 |