다형성이란 객체 지향 프로그래밍에서 중요한 개념 중 하나이다. 동일한 이름의 함수나 메소드가 서로 다른 방식으로 동작하는 객체 지향 프로그래밍의 특징이다. 프로그래밍에서는 같은 이름의 메서드가 서로 다른 객체에 따라 다르게 동작할 수 있다는 개념을 포함한다.
C++에서 다형성은 주로 가상 함수를 통해 구현되므로, 가상 함수에 대해 알아보고자 한다.
#include <iostream>
using namespace std;
class Base {
public:
void f() { cout << "Base called\n"; }
};
class Derived : public Base {
public:
void f() { cout << "Derived called\n"; }
};
int main() {
Derived d, *pDer;
pDer = &d;
pDer->f(); // Derived의 멤버 f() 호출
Base* pBase;
pBase = pDer; // 업 캐스팅, pBase는 객체 d를 가리킨다.
pBase->f(); // Base의 멤버 f() 호출
return 0;
}
// 출력
// Derived called
// Base called
pBase는 두 개의 f() 함수가 있지만, 파생 클래스의 함수가 호출되는 이유는 컴파일 단계에서 파생 클래스의 함수를 우선적으로 바인딩하기 때문이다.
재정의된 함수 중에서 기본 클래스의 함수를 호출하고 싶은 경우에는 업 캐스팅을 통해 pBase가 객체 d를 가리키도록 하면, pBase로 함수 기본 클래스의 f()를 호출할 수 있다.
가상 함수(virtual function)와 오버라이딩(overriding)은 상속에 기반을 둔 기술로 객체 지향 언어의 꽃이라고 볼 수 있다.
파생 클래스에서 기본 클래스에 작성된 가상 함수를 재작성하여, 기본 클래스에 작성된 가상 함수를 무력화하고, 객체의 주인이 되는 것이다.
기본 클래스의 포인터를 이용하든 파생 클래스의 포인터를 사용하든 가상 함수를 호출하면, 파생 클래스의 오버라이딩된 함수가 항상 실행된다.
가상 함수란 virtual 키워드로 선언된 멤버 함수로, 컴파일러에게 자신에 대한 호출 바인딩을 런타임 단계까지 미루는 키워드이다.
가상 함수는 기본 클래스나 파생 클래스 상관없이 어디든 선언 가능하다.
파생 클래스에서 기본 클래스의 가상 함수를 재정의하는 것을 함수 오버라이딩 혹은 오버라이딩이라고 한다.
가상 함수는 이름, 매개 변수 개수와 타입, 리턴 타입까지 모두 일치할 때 오버라이딩이 성공된다.
virtual 키워드를 사용하지 않은 경우에는 함수 재정이라고 하며 컴파일 시간 다형성(compile time polymorphism)이라 하고, virtual 키워드를 사용한 경우를 함수 오버라이딩이라 하며 실행 시간 다형성(run time polymorphism)을 실현한다.
따라서 함수 재정의라는 용어를 사용할 때에는 신중할 필요가 있는데, 프로그램의 실행이 완전히 달라지기 때문이다.
가상 함수를 재정의하는 오버라이딩의 경우 함수가 호출되는 런타임에서 동적 바인딩이 일어나지만, 그렇지 않은 경우 컴파일에서 결정된 함수가 단순히 정적 바인딩 되어 호출된다.
가상 함수가 선언된 클래스는 하나의 가상 테이블이 만들어지고 해당 클래스를 통해 만들어진 객체 주소의 첫번째 offset에 가상 함수 테이블의 주소가 할당된다. 가상 함수는 런타임에 가상 함수 테이블을 참조하여 함수를 호출하는 방식으로 약간의 오버헤드가 발생한다.
#include <iostream>
using namespace std;
class Base {
public:
virtual void f() { cout << "Base called\n"; }
};
class Derived : public Base {
public:
// virtual 생략 가능
virtual void f() { cout << "Derived called\n"; }
};
int main() {
Derived d, *pDer;
pDer = &d;
pDer->f();
Base* pBase;
pBase = pDer;
pBase->f(); // 동적 바인딩
return 0;
}
// 실행 결과
// Derived called
// Derived called
맨 처음 예제와 동일하지만 다른 점은 virtual 키워드가 선언되었다. 이로 인해 pBase는 업캐스팅 되었음에도 불구하고 파생 클래스의 f()가 동적 바인딩을 통해 호출되는 것을 확인할 수 있다.
추가로 가상 함수 virtual 속성은 상속되기 때문에 파생 클래스에서는 virtual를 생략해도 자동으로 가상 함수가 된다.
또한 가상 함수의 접근 지정은 보통 함수와 마찬가지로 private, protected, public 중에서 자유롭게 지정할 수 있다.
가상 함수를 만드는 목적은 파생 클래스들이 자신의 목적에 맞게 가상 함수를 재정의 하도록 하는 것이다.
기본 클래스의 가상 함수는 상속받은 파생 클래스에서 구현해야 할 함수 인터페이스를 제공한다. 즉, 가상 함수는 하나의 인터페이스를 오버라이딩을 통해 서로 다른 모양의 구현이라는 객체 지향 언어의 다형성을 실현하는 도구가 되는 셈이다.
가상 함수를 호출하는 코드를 컴파일할 때, 컴파일러는 바인딩을 런타임에 결정하도록 미루고, 나중에 가상 함수가 호출되면 실행 중에 객체 내에 오버라이딩된 가상 함수를 동적으로 찾아 호출한다. 이를 동적 바인딩(dynamic binding)이라고 하며, 동적 바인딩은 실행 시간 바인딩(run-time binding) 또는 늦은 바인딩(late binding)이라고도 부른다. 즉, 오버라이딩은 파생 클래스에서 재정의한 가상 함수의 호출을 보장받는 선언이라 할 수 있다.
기본 클래스의 객체에 대해서 가상 함수가 호출된다고 해도 동적 바인딩은 일어나지 않는다. 동적 바인딩은 파생 클래스의 객체에 대해, 기본 클래스의 포인터로 가상 함수가 호출될 때 일어난다.
가상 함수를 호출하면, 무조건 동적 바인딩을 통해 파생 클래스에 오버라이딩된 가상 함수가 실행된다.
오버라이딩에 의해 무시된 기본 클래스의 가상 함수는 범위 지정 연산자를 통해 호출 가능하다. 범위 지정 연산자를 이용하여 호출하면 정적 바인딩으로 호출하게 된다.
class Circle : public Shape {
public:
virtual void draw() {
Shape::draw(); // 기본클래스 Shape의 draw()실행.정적바인딩
// 필요한 기능 추가
}
};
위와 같이 오버라이딩된 함수에서 기본 클래스의 가상 함수의 기능을 호출하여 추가적으로 기능 작성이 가능하다.
#include <iostream>
using namespace std;
class Shape {
public:
virtual void draw() {
cout << "Shape";
}
};
class Circle : public Shape {
public:
int x;
virtual void draw() {
Shape::draw(); // 기본 클래스의 draw() 호출
cout << "Circle\n";
}
};
int main() {
Circle circle;
Shape *pShape = &circle;
pShape->draw(); // 동적 바인딩
pShape->Shape::draw(); // 정적 바인딩
return 0;
}
// 실행 결과
// ShapeCircle
// Shape
기본 클래스의 소멸자를 만들 때는 가상 함수로 작성할 것이 좋다. 왜냐하면 파생 클래스의 객체가 기본 클래스에 대한 포인터로 delete 되는 상황에서도 정상적인 소멸이 되도록 하기 위해서이다.
Base *p = new Derived();
delete p;
p가 Base 타입이므로 컴파일러는 ~Base() 소멸자를 호출하도록 컴파일한다. 그러므로 ~Base()만 실행되고 ~Derived()가 실행되지 않는다.
소멸자를 가상 함수로 선언하면 객체를 기본 클래스의 포인터로 소멸하든, 파생 클래스의 포인터로 소멸하든 파생 클래스와 기본 클래스의 소멸자를 모두 실행하게 된다.
~Base()에 대한 호출은 런타임 중에 동적 바인딩에 의해 ~Derived()에 대한 호출로 변하게 되어 ~Derived()가 실행된다.
#include <iostream>
using namespace std;
class Base {
public:
virtual ~Base() {
cout << "~Base()\n";
}
};
class Derived : public Base {
public:
virtual ~Derived() {
cout << "~Derived()\n";
}
};
int main() {
Derived *dp = new Derived();
Base *bp = new Derived();
delete dp;
delete bp;
return 0;
}
// 실행 결과
// ~Derived()
// ~Base()
// ~Derived()
// ~Base()
생성자는 가상 함수가 될 수 없으며, 가상 함수를 호출해도 동적 바인딩이 일어나지 않는다. 그러나 소멸자는 가상 함수가 될 수 있고, 가상 함수로 만드는 것이 바람직하다.
override 키워드는 오버라이딩하는 함수 끝에 사용하여 컴파일러로 하여금 오버라이딩이 정확한지 확인할 수 있다.
final을 가상 함수 끝에 사용하면 오버라이딩 할 수 없음을 뜻하고, 클래스 선언부에서 클래스의 이름 끝에 붙이게 되면 클래스를 상속 받을 수 없음을 뜻한다.
가상 함수는 파생 클래스에서 오버라이딩 할 함수를 알려주는 인터페이스 역할을 한다.
위 그림에서 Shape은 Circle, Rect, Line 등 도형의 공통 속성을 구현하는 기본 클래스의 역할을 한다. Shape은 가상 함수 draw()를 선언하여 파생 클래스에서 draw()를 오버라이딩 하여 자신의 도형을 그릴 수 있다.
위는 연결 리스트를 사용한 코드와 그림이다.
가장 핵심이 되는 코드는 다음과 같다.
Shape *p = pStart;
while (p != NULL) {
p->paint();
p = p->getNext();
}
Shape 타입의 포인터 p를 이용하여 연결된 모든 도형을 방문하면서 paint()함수를 호출하고, paint()는 동적 바인딩을 통해 Circle, Rect, Line 클래스에 오버라이딩 된 draw() 함수를 호출하는 것이다.
또 한가지 유의해서 볼 부분은 pStart, pLast, p의 타입이다. 이들은 모두 Shpae에 대한 포인터이므로 pLast는 Shape을 상속받은 어떤 객체든지 가리킬 수 있으므로 연결 리스트를 따라 이동하면서 파생 클래스의 객체들을 삽입할 수 있다. p의 경우 연결 리스트를 따라 이동하면서 Shape의 paint()를 호출하여 동적 바인딩으로 각 파생 클래스의 draw()를 호출하게 된다.
출처
명품 C++ Programming - 황기태
https://marmelo12.tistory.com/285
https://gdngy.tistory.com/178