상세 컨텐츠

본문 제목

C++ 가상 함수

C++/C++98

by deulee 2023. 8. 7. 17:29

본문

1. 객체와 포인터

가상 함수를 의논하기 전에 다음의 코드를 보도록 하자.

Parent P(1);
Child C(4, 6);

P = C; // 가능
C = P; // 불가능

부모 클래스와 자식 클래스가 있다고 할 때 부모 클래스는 자식 클래스로부터 대입이 가능하다.

 

하지만 반대로 자식 클래스는 부모 클래스로부터 대입이 불가능하다.

 

전자는 단순히 부모와 자식이 공통적으로 가지고 있는 멤버가 있기 때문에 가능하다, 하지만 자식은 부모에게 없는 멤버 변수가 더 있기 때문에 부모로 자식에게 대입은 불가능 한 것이다.

 

물론 이렇게 할 경우 정보들이 slice되겠지만 말이다.

 

그럼 포인터끼리는 어떻게 될까?

 

#include <iostream>

class Parent
{
private:
	int x;
public:
	Parent(int ax) : x(ax)
	{}
	void func()
	{
		std::cout << x << std::endl;
	}
};

class Child : public Parent
{
private:
	int y;
public:
	Child(int ax, int ay) : Parent(ax), y(ay)
	{}
	void func()
	{
		Parent::func();
		std::cout << y << std::endl;
	}
};

int main(void)
{
	Parent p(1)	;
	Child c(5, 6);

	Parent* ptrP;
	Child* ptrC;

	ptrP = &p; // 당연히 가능
	ptrC = &c; // 당연히 가능
	ptrP = &c; // 가능
	ptrC = &p; // 에러

	ptrC = (Child *)&p; // 가능은 하지만..
	ptrC->func();

	return 0;
}

 

맨처음에 말한 것처럼 부모 포인터는 자식 객체를 가리킬 수 있다.

 

하지만 "ptrC = (Child *)&p;" 구문 처럼 포인터에서는 강제로 타입을 치환하여 대입이 가능하기는 하다. 하지만, 이렇게 될 경우 "Child"의 멤버 중에는 초기화가 되어있지 않은 멤버도 존재하게 될 것이다. 그럼 쓰레기 값으로 초기화되면서 나중에 예기지 못한 상황이 발생할 수도 있다.

 

그렇기 때문에 부모로 자식에 대입하면 안되는 것이다.

 

그럼 만약 "ptrP = &c"상태에서 "ptrP->func()"를 호출하면 어떤 함수가 호출이 될까?

 

포인터는 두 가지 종류의 타입을 가지는데 다음과 같다.

 

1. 정적 타입(Static Type)

  • 포인터가 선언될 때의 타입, 즉 포인터 자체의 타입을 의미한다.

2. 동적 타입(Dynamic Type)

  • 포인터가 실행중에 가리키고 있는 대상체의 타입, 즉 대상체의 타입을 의미한다.
pP = &P; // 정적, 동적 타입 일치
pP = &C; // 정적, 동적 타입 불일치

바로 포인터의 정적 타입에 따라 호출되는 함수가 결정되게 된다. 즉, 부모의 "func()"이 호출될 것이다. (물론 다음에 나올 가상 함수에 의해 달라질 수 있음)

 

C++에서는 "상속 관계에 있는 클래스끼리 대입할 때 좌변이 더 상위의 클래스 타입이면 캐스팅을 하지 않고도 직접 대입할 수 있도록 허용"한다. 이래야만 다형성을 구현할 수 있기 때문이다.

 

물론 다중 상속 관계는 허용이 되지 않는다.

 

그럼 정리하자면 다음과 같다.

 

부모는 자식을 가리킬 수 있다.


2. 가상 함수의 개념

다음 예시를 보도록 하자.

#include <iostream>

class Parent
{
public:
	void func()
	{
		std::cout << "Parent Class" << std::endl;
	}
};

class Child : public Parent
{
public:
	void func()
	{
		std::cout << "Child Class" << std::endl;
	}
};

int main(void)
{
	Parent P;
	Child C;

	Parent* pP;
	pP = &P;
	pP->func();
	pP = &C;
	pP->func();
	return 0;
}

출력 결과

우리가 예상했던 결과는 다음의 출력 처럼 동적 타입에 맞게 호출되는 것이었지만,

Parent Class
Child Class

여지 없이 포인터의 정적 타입에 맞게 호출된 모습을 볼 수 있다. 이래서는 C++의 다형성이 뭔지 알지조차도 모른다.

 

이를 해결하기 위해 나온 키워드가 "virtual" 키워드며 이를 붙여진 함수가 가상 함수라고 한다.

 

그럼 다시 다음 예제를 보도록 하자.

 

#include <iostream>

class Parent
{
public:
	virtual void func() // 가상 함수로 선언
	{
		std::cout << "Parent Class" << std::endl;
	}
};

class Child : public Parent
{
public:
	virtual void func() // 가상 함수로 선언
	{
		std::cout << "Child Class" << std::endl;
	}
};

int main(void)
{
	Parent P;
	Child C;

	Parent* pP;
	pP = &P;
	pP->func();
	pP = &C;
	pP->func();
	return 0;
}

출력 결과

동적 타입대로 출력

 

짜잔!!

 

우리가 원하는데로 동적 타입에 따라 함수가 출력되는 것을 볼 수 있다.

 

부모의 멤버 함수가 가상 함수이면 자식의 멤버 함수도 자동으로 가상 함수가 되므로 "Child"의 "func"에는 굳이 "virtual" 키워드를 쓰지 않아도 된다. 하지만 양쪽에 붙이는 것이 바람직하며, "virtual" 키워드는 클래스 선언문 내에서만 쓸 수 있으며 함수 정의부에서는 사용할 수 없다. 그러니, 외부에서 정의할 때는 "virtual" 키워드 없이 함수의 본체만 기술해야 한다.

 

즉, 가상 함수란 포인터의 정적 타입이 아니라 동적 타입을 따르는 함수이다.

 

"func" 함수가 가상으로 선언되었으므로 pP가 가리키는 객체의 타입에 따라 누구의 멤버 함수를 호출할 것인가가 결정된다.

 

사실 저렇게 main 문에서 굳이 포인터로 호출할 필요는 없지만 함수로 객체를 넘길 때 많이 사용한다.

 

보통 객체는 크기가 커서 포인터나 레퍼런스로 값을 넘긴다. 즉, 이럴때 가상 함수의 진가가 나타나게 된다.

#include <iostream>

class Parent
{
public:
	virtual void func()
	{
		std::cout << "Parent Class" << std::endl;
	}
};

class Child : public Parent
{
public:
	virtual void func()
	{
		std::cout << "Child Class" << std::endl;
	}
};

void funcCallByPointer(Parent* ptr)
{
	ptr->func();
}

int main(void)
{
	Parent P;
	Child C;

	funcCallByPointer(&P);
	funcCallByPointer(&C);
	return 0;
}

출력 결과

 

이렇게 "funcCallByPointer()" 함수의 본체 코드는 완전히 똑같은데 전달되는 객체에 따라 실제 동작이 달라진다. 이것이 바로 다형성의 개념이다.

 


3. 동적 결합

그럼 컴파일러는 가상 함수 호출문을 어떻게 번역하는걸까?

 

일반적인 함수 호출은 컴파일러가 해당 함수가 어떤 주소에 있는지 알고 있기 때문에 어떤 함수가 호출이 된다면 해당 함수의 주소로 점프하는 코드로 번역할 것이다.

 

이렇게 컴파일하는 시점(정확하게는 링크 시점)에 이미 어디로 갈 것인가가 경정되는 것이 정적 결합(Static Binding)이라고 한다.

 

지금까지의 작성하고 사용했던 함수들은 모두 정적 결합에 의해 번역이 되었다.

 

하지만 가상 함수는 동적 타입에 따라 호출될 함수가 달라지므로 컴파일시에 호출할 주소를 결정하는 정적 결합으로는 불가능하다.

 

그렇기 때문에 실행중(Running Time)호출할 함수를 결정하는 이런 결합 방법을 동적 결합(Dynamic Binding)이라고 한다.

 

동적 결함은 다음 두 가지 상황에서만 동작한다.

 

1. 포인터로 멤버 함수를 호출

 

2. 레퍼런스로 멤버 함수를 호출


4. 가상 함수 테이블

그럼 동적 바인딩은 어떤 식으로 이루어지는 걸까?

 

if 문이나 switch 문 등으로 번역되는 것일까?

 

C++에서는 가상 함수 호출문을 어떤 식으로 구현해야 한다고 구체적으로 명시하고 있지는 않으며 강제하지도 않는다. 그래서 동적 결합의 구현 방법은 컴파일러마다 다르지만 C++의 요구에 맞게끔 구현만 되면 된다.

 

대부분의 컴파일러는 vtable이라는 가상 함수 목록을 작성하고 각 객체에 vtable을 가리키는 숨겨진 포인터 vptr을 추가하는 방식을 사용한다.

 

다음의 코드를 예시로 보도록 하자.

 

#include <iostream>

class Parent
{
private:
	int mP;
public:
	virtual void func()
	{
		std::cout << "Parent Class" << std::endl;
	}
	virtual void func2()
	{
		std::cout << "Parent F2" << std::endl;
	}
};

class Child : public Parent
{
private:
	int mC;
public:
	virtual void func()
	{
		std::cout << "Child Class" << std::endl;
	}
};

int main(void)
{
	Parent P;
	Child C;
	Parent* pP;

	pP = &P;
	pP->func();
	pP = &C;
	pP->func();
	pP->func2();
	return 0;
}

실행 결과

 

 

부모 클래스 "Parent"에 두 개의 가상 함수가 정의되어 있고 이를 상속받은 "Child" 클래스는 그 중 "func"을 재정의하고 있다.

 

이를 메모리에 구현된 모양을 그리면 다음과 같다.

 

사실상 우리가 명시적으로 적지는 않았지 가상 함수가 정의가 된다면 각 객체 마다 가상 함수 테이블(vtable)을 가리키는 포인터(vptr)을 가지고 있게 된다.

 

그렇기 때문에 호출문에서 동적 타입이 어떻게 처리되느냐에 따라 어떤 vptr이 호출되느냐를 결정하는 것이다.

 

즉, vtable을 사용하는 방법은 실행중에 호출할 함수를 결정한다기보다 호출할 함수의 목록을 vtable에 미리 작성하고 실행중에는 객체의 vptr을 통해 vtable을 찾고 vtable에서 다시 호출할 함수의 번지를 찾는 방법이다.

 

이렇게 컴파일할 때 모든 예비 동작을 미리 취해 놓고 컴파일러는 가상 함수 호출문을 객체에서 vtable을 찾고 vtable에서 함수 번지를 찾아 점프하는 문장으로 번역할 수 있다.

 

물론 컴파일 속도는 느려지겠지만 말이다. 하지만 실행중에는 굉장히 빠르다.

 

정리를 하자면 다음과 같다.

 

호출문 -> p -> vptr -> vtable[n]을 호출

 

그리고 가상 함수를 가진 클래스별로 vtable이라는 여분의 메모리를 더 소모하여 객체들도 vptr을 할당하기 위해 크기가 포인터의 크기만큼 더 커진다. 그래서 멤버 함수에 대한 결합 방법의 디폴트가 정적으로 되어 있으며 virtual 키워드를 사용할 때만 동적 결합을 하는 것이다.

 


5. 가상 함수의 활용[1]

가상 함수를 이용하게 되면 "if else if"나 "switch case"를 사용을 안해도 된다.

 

예를 들면 다음처럼 말이다.

  • 마우스 드래그 시 : Move 가상 함수 호출
  • 트래커 드래그 시 : Resize 가상 함수 호출
  • 더블 클릭 시 : SetProperty 가상 함수 호출

예제 코드

#include <iostream>

class Shape
{
public:
	virtual void draw()
	{
		std::cout << "Shape Class" << std::endl;
	}
};

class Rect : public Shape
{
public:
	virtual void draw()
	{
		std::cout << "Rect Class" << std::endl;
	}
};

class Circle : public Shape
{
public:
	virtual void draw()
	{
		std::cout << "Circle Class" << std::endl;
	}
};

class Triangle : public Shape
{
public:
	virtual void draw()
	{
		std::cout << "Triangle Class" << std::endl;
	}
};

int main(void)
{
	Shape* sh[5];

	sh[0] = new Shape;
	sh[1] = new Circle;
	sh[2] = new Triangle;
	sh[3] = new Rect;
	sh[4] = new Shape;
	for (int i = 0; i < 5; i++)
		sh[i]->draw();
	for (int i = 0; i < 5; i++)
		delete sh[i];
	return 0;
}

요런식으로 말이다.

 


 

6. 가상 함수의 활용 [2]

멤버 함수가 호출하는 함수는 어떻게 될까? 다음 코드를 보자.

#include <iostream>

class Shape
{
public:
	virtual void draw()
	{
		std::cout << "Shape Class" << std::endl;
	}
	virtual void erase()
	{
		std::cout << "Shape Erase" << std::endl;
	}
	void fillErase()
	{
		draw();
		std::cout << "is filled" << std::endl;
		erase();
	}
};

class Rect : public Shape
{
public:
	virtual void draw()
	{
		std::cout << "Rect Class" << std::endl;
	}
	virtual void erase()
	{
		std::cout << "Rect Erase" << std::endl;
	}
};

class Circle : public Shape
{
public:
	virtual void draw()
	{
		std::cout << "Circle Class" << std::endl;
	}
	virtual void erase()
	{
		std::cout << "Circle Erase" << std::endl;
	}
};

int main(void)
{
	Rect R;
	Circle C;

	R.fillErase();
	std::cout << std::endl;
	C.fillErase();
	return 0;
}

실행 결과

 

보면 "Rect" 클래스와 "Circle" 클래스가 "FillErase" 함수를 재정의 하지 않은 모습을 볼 수 있다.

 

그럼에도 멤버 함수가 호출된 객체에 따라 "FillErase" 함수 내에서 "Draw"와 "Erase"이 적절하게 호출되는 모습을 볼 수 있다. 이게 어떻게 가능할까?

 

결론부터 말하자면 this가 암시적으로 항상 인수로 넘어간다는 것을 기억해야 한다. 즉, "FillErase" 본체의 모습은 다음과 같다.

void fillErase(Shape* const this)
{
	this->draw();
	std::cout << "is filled" << std::endl;
	this->erase();
}

그렇기 때문에 다형성의 원리로 인해 각 객체에 알맞은 함수가 호출되는 것이다.

 

출처

http://www.soen.kr/lecture/ccpp/cpplec.htm

 

C/C++ 강좌

 

www.soen.kr

 

'C++ > C++98' 카테고리의 다른 글

C++ 순수 가상 함수  (0) 2023.08.07
C++ 가상 파괴자  (0) 2023.08.07
C++ [Has a] 관계  (0) 2023.08.07
C++ 다중 상속  (0) 2023.08.07
C++ 오버라이딩  (0) 2023.08.07

관련글 더보기