오버라이딩이란, 부모 클래스로부터 상속받은 메소드의 내용을 재정의(변경) 해야 하는 것을 오버라이딩이라고 합니다. 다른 말로 파생 클래스에서 기본 클래스에 작성된 가상 함수를 재작성하여 기본 클래스에 작성된 가상 함수를 무력화 시키고, 파생 클래스에서 재작성한 함수를 사용하는 것입니다.
무슨 소리이고 이런 걸 왜 하나 싶으실텐데, 하나의 예시를 들어보겠습니다.
우리가 자동차를 하나 샀습니다. 자동차를 샀으면 우리는 운전을 하겠죠? 운전을 하기 위해 '엑셀' 과 '브레이크' 라는 것을 통해 운전을 하게 될 겁니다. 이를 간단히 클래스로 표현해보면
class Car{
int speed;
public:
virtual void Accelerator() {}
virtual void Break() {}
...
}
이런 식이 될 겁니다. virtual 키워드는 처음 보실 텐데 virtual 키워드로 선언한 함수를 가상 함수라고 합니다. 그건 좀 이따 다시 알아보고, 우리가 엑셀과 브레이크를 어떻게 조작하죠? 바로 브레이크 패드와 엑셀 패드를 발로 밟으면서 동작을 하게 됩니다. 우리가 엑셀을 발로 밟으면 자동차 내에서는 어떤 일이 일어날까요?
실제로는 위처럼 생각보다 아주 복잡하고 어려운 일이 일어나서 자동차가 앞으로 나아가게 됩니다. 그런데 운전자가 이런 동작을 알아야만 운전을 할 수 있나요? 아니죠. 우리는 딸깍딸깍 페달을 밟는 것만으로 저런 복잡한 동작을 컨트롤할 수 있게 됩니다. 이를 아주 간단한 class 형식으로 나타내보면
class Car{
int speed;
public:
virtual void Accelerator() {}
virtual void Break() {}
...
}
class Motor : public Car{
public:
virtual void Accelerator() {
// 엄청 복잡한 모터 어쩌구...
speed += ...
}
virtual void Break() {
// 엄청 복잡한 모터 어쩌구...
speed -= ...
}
...
}
이런 식으로 나타낼 수 있습니다. 운전할 때 실제로 동작하는 것은 Car 클래스의 액셀이랑 브레이크 일까요, Motor 의 엑셀과 브레이크일까요? 당연히 Motor 의 엑셀과 브레이크가 주 동작입니다. 위 코드를 잘 보면 각 클래스 내의 함수의 이름이 똑같습니다. 지금 한 것이 overriding 입니다. 만약 우리가 main 함수에서 Break 라는 함수를 호출하면 Car 클래스의 Break 함수가 호출될까요, Motor 클래스의 Break 함수가 호출될까요? 당연히 실제 동작을 하는 Motor 클래스의 Break 가 호출되어야 할 것입니다.
이런 것을 할 수 있게 해주는 것이 가상함수와 오버라이딩인데요, 이를 쓰는 이유는 반대로 말하면 모터의 동작 원리를 몰라도 우리가 운전을 할 수 있듯, 사용자에게 몰라도 되는 기능을 감출 수도 있고(이를 다른 말로 인터페이스를 제공한다 라고도 합니다.), 혹은 보안 상으로 유출하면 안되는 코드(자동차의 구성 원리? 경쟁력 등)를 이를 통해 감출수도 있고, 나중에 더 체감하겠지만 이를 응용해 설계와 구현을 구분(먼저 모터 만들어놓고, 패달을 다른 방식으로 붙이는 등)할 수 있다는 점입니다. 또한 이러한 오버라이딩을 사용하면 같은 함수를 다르게 재정의하여 사용할 수 있습니다. 마치 스마트폰 클래스에 "Market" 이라는 함수가 있다면 갤럭시에서는 그 세부 내용을 Play store 로 바꿔서 사용하고, 아이폰에서는 app store 로 바꿔서 사용할 수가 있는 것이죠. 이런 특징을 바로 다형성 이라고 합니다. 이처럼 가상함수와 오버라이딩을 사용하면 같은 이름의 함수를 파생 클래스마다 다르게 구현할 수 있습니다.
이제 본격적으로 가상함수가 무엇이고 오버라이딩이 무엇인지, 사용 예시 코드와 함께 알아보도록 하겠습니다.
가상 함수 ( virtual function)
가상 함수란, virtual 키워드로 선언된 멤버 함수 를 말합니다. virtual 키워드는 컴파일러에게 자신에 대한 호출 바인딩을 실행 시간까지 미루도록 지시하는 키워드 입니다. 바인딩이니, 처음 보는 용어가 등장해 낯설 수 있지만, 결국 가상 함수란 뭔가 늦게 실행되는 멤버함수라는 말입니다. 이런 가상 함수는 기본 클래스나 파생 클래스 어디에서든 선언하여 사용할 수 있습니다.
우리가 위에서 Car 클래스에서 정의된 Accelerator() 함수와 Break() 함수를 Motor 클래스에서 또 다시 재정의했습니다. 이처럼 파생 클래스에서 기본 클래스의 가상 함수를 재정의 하는 것 을 함수 오버라이딩(fucntion overriding) 이라고 합니다. 여기서 조금 헷갈릴 수 있습니다. 왜냐면 우리는 이전에 함수 오버 로딩 (function overloading ) 이라는 것을 배웠고, 그거랑 매우 유사해보이기 때문이죠. 그러나 이 두 개념은 모두 다형성의 구현에 중요한 개념은 맞으나 하는 역할이 다릅니다.
먼저 함수 오버로딩은 같은 이름의 함수를 매개변수의 개수나 타입(함수 시그니처) 에 따라 다른 구현으로 여러 개 정의하는 것이었습니다. 오버로딩의 조건이 함수 시그니처가 달라야했습니다. 자세한 내용은 아래 포스팅을 참고해주세요.
반면 이번에 알아볼 오버라이딩은 기본 클래스에서 선언된 가상 함수를 파생 클래스에서 다시 정의하는 것입니다. 상속의 개념이 추가되었고, 가상 함수라는 개념이 추가되었습니다. 또한, 오버라이딩은 매개변수의 타입이나 개수가 다르면 안됩니다.
#include <iostream>
using namespace std;
class Base {
public:
virtual void f() { cout << "Base::f() called" << endl; }
};
class Derived : public Base{
public:
void f() { cout << "Derived::f() called" << endl; }
};
int main() {
Derived d, * pDer;
pDer = &d;
pDer->f(); // Derived::f 호출
Base* pBase;
pBase = pDer; // 업캐스팅
pBase->f(); // 동적 바인딩 발생, Derived::f() 실행
}
위 같은 예시를 살펴봅시다. f() 라는 함수가 기본 클래스에서 정의 되어있고 기능도 있습니다. 그런데 가상 함수로 선언되어있죠. 파생 클래스에서 다시 f() 라는 함수를 오버라이딩 했습니다. 이런 경우 Derived 객체 d 에는 두 개의 f() 함수가 존재하게 됩니다. 원래 위 코드를 virtual 키워드 없이 선언하게 되면, 포인터의 타입에 따라 Base 의 f() 를 호출할지, Derived의 f() 를 호출할지가 결정됩니다. 위 코드만 보고 이상함을 느끼셨으면 정말 이해를 잘하고 있는건데, Base의 포인터인 pBase 는 d 객체에서 파생 클래스의 멤버에는 접근할 수 없고, 오직 기본 클래스의 멤버에만 접근할 수 있기 때문에 원래 Base 의 f() 를 호출하는 것이 더 정상적입니다. 마치 지금 아직 선언되지도 않은 함수를 호출(이를 통해 설계와 구현을 분리) 하고 있는 것이죠. 만약 위 코드에서 virtual 키워드를 지우게 되면
#include <iostream>
using namespace std;
class Base {
public:
void f() { cout << "Base::f() called" << endl; }
};
class Derived : public Base{
public:
void f() { cout << "Derived::f() called" << endl; }
};
int main() {
Derived d, * pDer;
pDer = &d;
pDer->f(); // Derived::f 호출
Base* pBase;
pBase = pDer; // 업캐스팅
pBase->f(); // Base의 f() 를 호출
}
업 캐스팅을 했기 때문에 기본 클래스의 포인터인 pBase 는 Derived 멤버에 접근할 수 없습니다. 따라서 원래는 Base의 f()가 호출되어야 정상입니다. 이런 호출 관계는 컴파일 시에 결정(정적 바인딩)됩니다.
그럼 virtual 이 뭔가 역할을 하고 있는거죠? 위에서 했던 말을 다시 해보면 virtual 키워드란 컴파일러에게 자신에 대한 호출 바인딩을 실행 시간까지 미루도록 지시하는 키워드 입니다. 이를 다른 말로 동적 바인딩이라고 합니다. 동적 바인딩은 프로그램 실행 중에 결정되는 것을 말하는데, 마치 배열을 정적으로 선언하는 것과 동적으로 선언하는 것과 같은 차이라고 생각하셔도 됩니다.
virtual 키워드를 사용하면 동적 바인딩, 즉 컴파일 시점이 아니라 프로그램 실행시점까지 늦춰지는 겁니다. 동적 바인딩이란 virtual 키워드를 사용하고 오버라이딩 된 함수가 있다면 무조건 파생 클래스의 함수를 호출하게 되는 것을 말합니다. 우리가 브레이크 패드를 밟았는데 차가 안 멈추면 큰일 나잖아요?
즉 요약 하자면 virtual 키워드를 사용한 함수는 가상 함수이고, 이런 함수들은 정적 바인딩을 하지 않고 동적 바인딩을 한다. 동적 바인딩을 할 때 파생 클래스에 오버라이딩 된 함수가 있다면, 그 함수를 호출하여 실행하게 된다.
오버라이딩의 전제조건
기본 규칙
- pointer type에 따라 선언된 범위의 멤버 함수를 수행한다.
- pBase → f() // Base class 의 f()를 수행
- pDer → f() // Derived class 의 f()를 수행
오버라이딩의 전제 조건
- virtual 키워드 사용
- 함수 이름과 매개변수 타입, 개수, 리턴 타입까지 일치해야 한다.
- 업캐스팅(pointer)
#include <iostream>
using namespace std;
class Base {
public:
virtual void f() { cout << "Base::f() called" << endl; }
};
class Derived : public Base {
public:
void f() { cout << "Derived::f() called" << endl; }
};
class GrandDerived : public Derived {
public:
void f() { cout << "GrandDerived::f() called" << endl; }
};
int main()
{
GrandDerived g;
Base* bp = &g; // GrandDerived 업 캐스팅
Derived* dp = &g; // GrandDerived 업 캐스팅
GrandDerived* gp = &g;
bp->f(); dp->f(); gp->f();
Base* Bp = new Derived(); // Derived 업 캐스팅
Bp->f();
Base B;
bp = &B;
bp->f(); // 업캐스팅을 안했으니 Base 의 f() 호출
}
위처럼 상속이 반복되는 경우에도 기본 규칙이 성립합니다. 기본 규칙에 따라 호출되고, 여기에서 업 캐스팅이 되는 순간 기본 or 상위 파생 클래스의 virtual 키워드를 통해 맨 아래의 f() 가 호출되게 됩니다. 위 코드만 천천히 봐도 이해하실 수 있습니다. 여기까지 가상함수와 오버라이딩의 개념에 대해 알아보았습니다. 틀린 점이 있다면 댓글로 남겨주세요. 감사합니다.
'C++ > C++ 기초' 카테고리의 다른 글
[C++] 순수 가상 함수와 추상 클래스 ( Pure virtual function and Abstract class ) (0) | 2023.07.17 |
---|---|
[C++] 파생 클래스와 기본 클래스의 생성자/소멸자 호출 관계 + 가상 소멸자 (0) | 2023.07.17 |
[C++] 상속 (Inheritance) - 업캐스팅/다운캐스팅 (Up/Down casting) (0) | 2023.07.17 |
[C++] 연산자 중복 (Operator overloading) + 프렌드(friend) 개념 (0) | 2023.07.13 |
[C++] 함수 중복과 디폴트 매개변수 (Function overloading and default parameter) (0) | 2023.07.11 |