본문으로 바로가기
반응형

가상함수

객체와 포인터

변수끼리 대입할때는 좌우변의 타입이 같거나 호환되어야 한다.

객체끼리의 대입도 마찬가지인데 사람과 시간처럼 전혀 관련 없는 객체끼리 대입할 수 없다. 

그러나 상속관계에 있는 객체끼리는 타입이 달라도 어느 정도 대입이 허용된다.

 

자식이 부모 객체로부터 필요한 정보만 복사하고 나머지는 적당한 디폴트를 취하는 대입연산자를 정의하면 역방향의 대입도 가능하지만 일반적이지 않다.

요약하자면 부모 포인터로 자식을 가리킬 수 있지만, 자식 포인터로는 부모를 가리킬 수 없다.

 

 

가상 함수의 개념

부모 타입의 포인터가 자식 객체를 가리키는 상황에서 이 포인터로 멤버함수를 호출하면 어떤 함수가 호출될까?

포인터의 정적 타입을 따를 수도 있고 동적타입을 따를 수도 있는데 다음 예제를 살펴보자.

// 파일이름 : VirtFunc.cpp
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <string.h>

class Human
{
protected:
	char name[12];
	int age;

public:
	Human(const char* name, int age)
	{
		strcpy(this->name, name);
		this->age = age;
	}

	void intro()
	{
		printf("이름 : %s\n", this->name);
		printf("나이 : %d\n", this->age);
	}
};

class Student : public Human
{
protected:
	int stunum;

public:
	Student(const char *name, int age, int stunum) : Human(name, age)
	{
		this->stunum = stunum;
	}

	void intro()
	{
		printf("%d학번 %s입니다.", this->stunum, name);
	}

	void study()
	{
		printf("이이는 사, 이삼은 육, 이사 팔\n");
	}
};

int main()
{
	Human h("김사람", 10);
	Student s("이학생", 15, 1234567);
	Human* pH;

	pH = &h;
	pH->intro();
	pH = &s;
	pH->intro();
}

Human으로부터 파생된 Student 클래스는 intro 메서드를 상속받은 후 학번과 이름을 출력하도록 재정의했다.

Human 타입의 포인터 pH는 Human 객체와 Student 객체를 모두 가리킬 수 있는데 각 상태에서 pH로 intro를 호출했다. 어떤 타입을 따르는가에 따라 호출된 intro 함수가 달라진다.

 

pH가 Human 객체를 가리키고 있을때는 정적타입과 동적타입이 똑같아서 Human::intro()를 호출하는 것은 당연하다.

그러나 pH가 Student 객체를 가리킬때는 정적타입을 따를지, 동적타입을 따를지 결정해야한다.

 

pH가 Human * 타입으로 선언되어 있으니 Human::intro()를 호출하는 것이 합당한 것 같기도 한데 실제 가리키는 객체는 Student 타입의 객체이므로 Student::intro()를 호출하는 것이 맞는것 같기도하다.

예제의 실행 결과를 봤을때는 포인터의 정적 타입을 따른다는 것을 알 수 있다.

 

pH가 Student 객체를 가리키고 있더라도 선언할때 Human*로 선언했으니 이 포인터로 intro를 호출하면 Human::intro() 함수가 선택된다. pH가 가리키는 실제 객체의 타입에 따라 적절한 함수가 호출될 것을 기대했다면 프로그램이 원하는대로 동작하지 않은 것이다.

 

물론 위 예제의 경우 pH로 Student 객체를 가리키지 말고 Student* 타입의 pS를 선언하여 s객체를 가리키면 깔끔하게 해결된다. 그러나 함수를 통해 객체를 주고받을 때는 타입별로 일일이 함수를 만들 수 없어 대표 타입을 받는다. 가령 임의의 사람을 전달받아 소개 함수를 호출하는 함수를 만든다고 해보자.

void IntroSomeBody(Human *pH)
{
	pH->intro();
}

인수는 Human * 타입이지만 사람으로부터 파생된 모든 객체를 다 받을 수 있고 학생도 당연히 가능하다. 학생은 일종의 사람이고 소개하는 동작을 할 수 있기 때문이다. 이렇게되면 형식 인수 pH가 받는 객체에 따라 실제 호출할 함수가 달라져야한다. 포인터의 정적타입과 동적타입이 다를때 정적 타입을 따르도록 되어있지만 실제 코드에서는 동적 타입을 따르는 것이 더 합당한 경우도 있다.

 

이럴때 사용하는 것이 바로 가상 함수이다.

가상 함수는 포인터의 동적 타입에따라 실제 호출할 함수가 결정된다.

포인터가 가리키는 타입에따라 호출할 함수를 결정하려면 멤버 함수 앞에 virtual 키워드를 붙여 가상으로 선언한다.

// 파일이름 : VirtFunc2.cpp
// intro에 virtual을 추가해보자.
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <string.h>

class Human
{
protected:
	char name[12];
	int age;

public:
	Human(const char* name, int age)
	{
		strcpy(this->name, name);
		this->age = age;
	}

	virtual void intro()
	{
		printf("이름 : %s\n", this->name);
		printf("나이 : %d\n", this->age);
	}
};

class Student : public Human
{
protected:
	int stunum;

public:
	Student(const char* name, int age, int stunum) : Human(name, age)
	{
		this->stunum = stunum;
	}

	virtual void intro()
	{
		printf("%d학번 %s입니다.", this->stunum, name);
	}

	void study()
	{
		printf("이이는 사, 이삼은 육, 이사 팔\n");
	}
};

void IntroSomeBody(Human* pH)
{
	pH->intro();
}

int main()
{
	Human h("김사람", 10);
	Student s("이학생", 15, 1234567);
	Human* pH;

	pH = &h;
	pH->intro();
	pH = &s;
	pH->intro();
}

intro 함수를 가상으로 선언했으므로 이 함수를 호출할 때는 포인터의 동적 타입을 따른다.

IntroSomebody 함수로 어떤 객체를 넘기는가에 따라 실제 호출할 함수가 달라진다.

Human 객체 h를 pH로 넘기면 Human::intro() 가 호출되지만 Student 객체 s를 pH로 넘기면 Student::intro()가 호출된다.

 

IntroSomeBody 함수 본체는 pH->intro(); 로 정해져있지만 pH로 어떤 객체를 넘기는가에따라 호출될 함수가 결정되어 실제 동작이 달라진다. 똑같은 코드이지만 경우에 따라 다르게 동작하는 것이다.

이것이 바로 다형성이다.

 

 

동적 결합

가상 함수는 자신을 호출하는 객체의 실제 타입인 동적 타입에따라 호출할 함수를 결정한다.

컴파일러는 printf 함수의 정의를 알고있으며, 링커는 함수의 주소로 점프하는 코드를 작성한다.

컴파일(링크)하는 시점에 점프할 주소가 결정되는 방식을 정적결합 또는 이른 결하빙라고 한다.

결합이란 함수의 번지를 결정하는 동작인데 지금까지 사용한 함수는 모두 정적결합으로 동작한다.

 

그러나 가상함수는 객체의 타입에 따라 바인딩이 달라져야 하므로 컴파일할때 호출 주소를 정확히 결정할 수 없다.

대입은 실행 중에 발생하는 연산이며 포인터가 실행 중에 어떤 타입의 객체를 대입받을지 미리 알 수 없기 때문이다.

IntroSomeBody 함수의 본체 코드는 무조건적인 점프문이 될 수 없으며 조건에 따라 호출할 함수를 선택해야한다.

void IntroSomeBody(Human *pH)
{
	pH가 Human 객체를 가리키면 Human::intro() 호출
    pH가 Student 객체를 가리키면 Student::intro() 호출
}

 

실행 중에 호출할 함수를 결정하는 방식을 동적결합 또는 늦은 결합이라고 한다.

pH->intro() 호출문을 미리 정해진 번지로의 점프문으로 번역할 수 없고 pH가 실제 가리키는 타입을 조사하여 정확한 함수를 호출해야한다. 그래야 전달된 객체에 따라 각기 다른 동작을 할 수 있고 다형성을 구현할 수 있다.

 

 

그렇다면 똑같은 코드에 대해 경우에 따라 다른 함수를 호출하는 코드는 어떻게 만들 수 있을까?

타입을 판별하는 if문이나 switch 문을 쓸 수도 있고 별도의 함수 목록을 작성할 수도 있다.

대부분의 컴파일러는 가상 함수 테이블 형식으로 구현한다. 내부는 다소 복잡하게 되어있는데 너무 상세히 알 필요는 없고 클래스마다 가상 함수의 번지 목록인 가상테이블을 작성하고 각 객체는 선두에 가상테이블의 위치를 가진다. 가상 함수 호출문은 객체의 가상테이블을 참고하여 점프할 함수의 실제 번지를 찾는 과정이다. 클래스마다 룩업 테이블을 유지하고 이 테이블에서 실제 함수의 번지를 찾는 형식이다.

 

가상함수를 위해 객체의 구조가 달라지고 호출문 번역이 복잡해져 동적 결합은 정적 결합보다 호출 속도가 근소하게 느리다. 성능을 중요시하는 C/C++은 정적 결합을 할지 동적 결합을 할지 virtual 키워드로 선택하도록 되어있다.

반면 속도보다는 정확성을 더 중요시하는 자바는 무조건 동적 결합을 한다. 가상 함수 호출문을 어떻게 번역할 것인가는 컴파일러 제작의 문제이므로 상세한 부분은 다음에 알아보도록 하자.

 

 

가상함수의 활용

재정의 가능한 함수

가상 함수는 이해도 잘 되지않고 실무에서 가상 함수를 제대로 활용하기는 더 어려워보인다.

보통은 동적 결합이 꼭 필요할때, 즉 프로그램이 실행되면서 결합할때 가상 함수를 사용한다.

(나중에는 이런 함수가 필요할것 같으니 정확한 동작은 니네가 구현하고 이것의 틀은 내가 구현하겠다는 뜻..?)

 

가상 함수는 포인터의 동적 타입에 따라 정확한 함수가 선택되는 함수이다.

따라서 가상 함수가 되려면 상속 계층의 클래스마다 재정의되어 동작이 달라야한다.

부모가 정의한 함수를 자식이 수정할 가능성이 있는 함수는 가상으로 선언한다.

반면 재정의할 필요가 전혀 없는 함수는 굳이 가상으로 선언할 필요가 없다.

(가상함수는 미래를 예측해서 언젠가는 필요하다는 생각하에 지금 내가 설계해놓는 것임)

// 파일이름 : Overridable.cpp
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <string.h>

class Human
{
protected:
	char name[12];
	int age;

public:
	Human(const char* name, int age)
	{
		strcpy(this->name, name);
		this->age = age;
	}

	void eat()
	{
		puts("냠냠");
	}

	virtual void intro()
	{
		printf("이름 : %s\n", this->name);
		printf("나이 : %d\n", this->age);
	}
};

class Student : public Human
{
protected:
	int stunum;

public:
	Student(const char* name, int age, int stunum) : Human(name, age)
	{
		this->stunum = stunum;
	}

	void intro()
	{
		printf("%d학번 %s입니다.\n", this->stunum, this->name);
	}
};

int main()
{
	Human h("김사람", 10);
	Student s("이학생", 15, 1234567);
	Human* pH;

	pH = &h;
	pH->intro();
	pH->eat();
	pH = &s;
	pH->intro();
	pH->eat();
}

Human을 짤때는 개발자가 5살일때 사용한 클래스이다.

근데 시간이 지나면서 내가 학생이되더니 나를 소개하려면 학번도 필요해졌다.

 

따라서 Human의 기능을 그대로 사용할 수 있으면서 나의 학번을 소개하는 intro 함수를 오버라이드를해서 클래스를 상속받으면서 새 만든다.

근데 intro 함수는 타입에따라 intro가 Human이나 Student로 달라져서 나올텐데 곧 달라질 타입들을 대비해서 Human의 intro를 virtual화 시켜서 만드는 것이다.(미래에 대비해서 가상함수로 만드는 것임)

 

따라서 Human에 있는 intro()를 virtual로 해놓는 것이다.

 

intro 함수는 Human과 Student의 동작이 달라 Student에서 재정의한다.

따라서 이 함수는 반드시 가상으로 선언해야한다.

반면 eat함수는 Human에만 정의되어있고 Student는 이 함수만 상속받아 쓰기만 한다.

 

어떤 사람이든 먹는다 라는 행위에는 별반 차이가 없을테니 eat 함수를 굳이 재정의할 필요는 없다.

후손 객체가 eat를 호출하더라도 실제 호출될 함수는 항상 Human::eat로 정해져있다.

따라서 이 함수는 동적 결합할 필요가 없으며 가상으로 선언하지 않는 것이 좋다.

 

동적 결합은 포인터(또는 레퍼런스)를 통해 멤버 함수를 호출할 때만 적용된다.

부모가 자식을 가리킬 수 있다보니 포인터의 정적, 동적 타입이 일치하지 않고 그래서 동적 결합이 필요하다.

객체로부터 함수를 직접 호출할때는 객체의 타입이 분명해 항상 정적결합된다.

 

h는 어떻게해도 Human 타입일 수밖에 없으니 호출되는 함수는 항상 Human::intro()이다.

 

 

객체의 집합관리

해당 예제는 여러가지 도형을 관리하는 그래픽 편집 프로그램을 매우 간략화하고 간단하게 글로만 표현한 예시이다.

최상위 멤버 Shape 클래스로부터 여러 가지 모양을 표현하는 도형 객체가 파생된다.

모두 draw라는 멤버 함수로 자신을 그리고, 도형마다 그리는 방식이 달라 모든 클래스가 draw를 재정의한다.

#include <stdio.h>

class Shape
{
public:
	void draw() { puts("도형 오브젝트입니다."); }
};

class Line : public Shape
{
public:
	void draw() { puts("선을 긋습니다."); }
};

class Circle : public Shape
{
public:
	void draw() { puts("동그라미 그렸다 (치고,)"); }
};

class Rect : public Shape
{
public:
	void draw() { puts("요건 사각형입니다."); }
};

int main()
{
	Shape * ar[] = {new Shape(), new Rect(), new Circle(), new Rect(), new Line()};

	for (int i = 0; i < sizeof(ar) / sizeof(ar[0]); i++)
	{
		ar[i]->draw();
	}

	for (int i = 0; i < sizeof(ar) / sizeof(ar[0]); i++)
	{
		delete ar[i];
	}
}

이렇게 출력되는 이유는 draw 함수가 비가상이어서 정적결합되기 때문이다.

ar 배열의 정적 타입이 Shape*이니 배열 요소로부터 함수는 항상 Shape::draw()만 일 수 밖에 없다.

하나만 출력되는 이슈를 해결하려면 draw함수가 동적 결합하도록 가상으로 선언해야한다.

루트의 draw만으로 가상으로 선언하면 파생 클래스의 함수도 자동으로 가상이된다.

 

모든 draw함수를 가상화 시켜보자.

#include <stdio.h>

class Shape
{
public:
	virtual void draw() { puts("도형 오브젝트입니다."); }
};

class Line : public Shape
{
public:
	virtual void draw() { puts("선을 긋습니다."); }
};

class Circle : public Shape
{
public:
	virtual void draw() { puts("동그라미 그렸다 (치고,)"); }
};

class Rect : public Shape
{
public:
	virtual void draw() { puts("요건 사각형입니다."); }
};

int main()
{
	Shape* ar[] = { new Shape(), new Rect(), new Circle(), new Rect(), new Line() };

	for (int i = 0; i < sizeof(ar) / sizeof(ar[0]); i++)
	{
		ar[i]->draw();
	}

	for (int i = 0; i < sizeof(ar) / sizeof(ar[0]); i++)
	{
		delete ar[i];
	}
}

모든 draw를 가상으로 선언하면 호출 객체의 타입에따라 동적으로 결합되므로 각 도형에 맞는 draw함수가 호출된다.

똑같은 ar[i] -> draw() 호출임에도 ar[i]가 실제 어떤 도형인가에따라 그려지는 모양이 달라진다.

가상함수의 동작이 다형적이라고 볼 수 있다.

 

타입을 구분할 필요없이 ar[i]에 대해 draw만 호출하면 가상 함수가 동적 결합되어 객체 타입에 맞는 정확한 함수를 선택한다.

이런 기능이 없다면 도형별로 자신의 타입을 밝히는 멤버를 선언해 두고 관리 코드는 배열을 순회하며 도형의 타입에따라 호출할 함수를 선택해야한다.

 

 

가상 소멸자

소멸자는 항상 가상으로 선언해야한다.

// 파일이름 : VirtDestuructor.cpp
#include <stdio.h>

class Base
{
private:
	char* B_buf;

public:
	Base() 
	{ 
		B_buf = new char[10];  
		puts("Base 생성");
	}

	virtual ~Base()
	{
		delete[] B_buf;
		puts("Base 파괴");
	}
};


class Derived : public Base
{
private:
	int* D_buf;
public:
	Derived()
	{
		D_buf = new int[32];
		puts("Derived 생성");
	}

	virtual ~Derived()
	{
		delete[] D_buf;
		puts("Derived 파괴");
	}
};


int main()
{
	Base* pB;

	pB = new Derived;

	delete pB;  
}

Base는 생성자에서 문자 배열으 생성하고 파괴자에서 해제한다. Base로부터 파생된 Derived는 생성자에서 정수 배열을 생성하고 소멸자에서 해제한다. 각 클래스가 필요한 메모리를 동적 할당하지만 생성자와 소멸자가 정확하게 작성되어있어서 메모리 누수는 없을 것이다.

 

만약 virtual을 선언하지 않았다면 부모가 할당한 배열은 잘 해제되지만 Derived가 할당한 배열은 해제되지 않아 메모리 누수가 발생한다.

소멸자를 가상소멸자로 선언하지 않았을때

문제의 원인은 소멸자가 정적 결합을 하기때문이며 소멸자를 가상으로 선언하면 해결된다.

 

 

순수 가상 함수

가상 함수는 동적 결합 능력이 있어 포인터로 호출해도 정확한 함수가 선택되므로 안전하게 재정의할 수 있다.

그러나 반드시 재정의할 필요는 없는데, 부모의 동작을 그대로 쓰고 싶다면 상속만 받으면되고, 동작을 변경하고 싶을때만 재정의하면 된다.

즉, 가상 함수는 재정의 가능한 함수이지 반드시 재정의해야 하는 것은 아니다.

 

이에 비해 순수 가상 함수는 부모 클래스가 동작을 아예 정의하지 않아 파생 클래스에서 반드시 재정의해야하는 함수이다. 부모 클래스가 너무 일반적이어서 동작을 정의할 수 없을대 함수의 본체를 생략하고 선언부의 끝에 = 0을 붙여 본체가 없음을 표시한다.

virtual void func() = 0;

 

다음 예제는 앞에서 작성했던 도형 예제의 draw함수가 대표적인 예시를 들어봤다.

// 파일이름 : PureVirtual.cpp
#include <stdio.h>

class Shape
{
public:
	virtual void draw() = 0;
};


class Line : public Shape
{
public:
	virtual void draw() { puts("선을 긋습니다."); }
};


class Circle : public Shape
{
public:
	virtual void draw() { puts("동그라미 그렸다 치고,"); }
};

class Rect : public Shape
{
public:
	virtual void draw() { puts("요건 사각형입니다"); }
};

int main()
{
	Shape* pS[3];

	// Shape s;   // 에러
	pS[0] = new Line;
	pS[1] = new Circle;
	pS[2] = new Rect;

	for (int i = 0; i < 3; i++)
	{
		pS[i]->draw();
	}

	for (int i = 0; i < 3; i++)
	{
		delete pS[i];
	}
}

Shape 클래스는 일반적인 도형을 정의할 뿐 실제로 화면에 자신을 그릴 수 있는 구체적인 도형은 아니다.

그래서 draw 함수의 본체를 정의할 수 없으며 = 0 을 붙여 순수 가상 함수로 선언했다.

순수 가상 함수를 하나 이상 가지는 클래스를 추상클래스(Abstract Class)라고 하며, 동작 중 일부가 정의되지 않아 인스턴스를 생성할 수 없다. 그래서 Shape 타입의 s 객체를 선언하면 에러 처리된다.

 

추상클래스가 정의하는 기능 목록을 인터페이스라고한다. Shape 클래스가 선언하는 3개의 순수 가상 함수는 비록 본체는 없지만 "도형이 되려면 적어도 이 정도의 기능은 꼭 필요하다"는 것을 표시하고 개발자에게 구현을 강제하는 역할을 한다. 그리기, 이동, 크기 변경 중 하나라도 수행할 수 없다면 실세계의 도형이라고 할 수 없다.

 

순수 가상 함수는 필요한 기능의 목록을 밝히는 것이 본연의 임무여서 본체를 가지지 않는 것이 보통이다.

추상 클래스는 필요한 동작의 종류만 밝히고 파생 클래스가 재정의하여 본체를 채운다. 일반적이지 않지만 순수 가상 함수도 후손들의 공통적인 동작을 처리하기 위해 본체를 가질 수도 있다.

class Shape
{
public:
	virtual void draw() = 0
    {
    	clrscr();
    }
};

모든 도형이 그리기 전에 화면을 지워야한다면 각 클래스의 draw마다 clrscr 호출문을 넣는 대신 루트의 draw에 이 코드를 미리 작성해 놓을 수 있다. 후손 클래스는 Shape::draw()를 호출하여 준비를 한 후 자신을 그린다.

class Line : public Shape
{
public:
	virtual void draw() {Shape::draw(); puts("선을 긋습니다"); }
};

모든 후손이 공통으로 사용해야 할 코드를 루트의 순수 가상 함수에 작성해 놓으면 반복을 방지 할 수 있고 수정도 용이하다. 그러나 본체를 가지더라도 = 0으로 선언했으면 여전히 순수 가상이며 Shape 클래스는 객체를 생성할 수 없는 추상클래스이다. 

 

가상 클래스는 클래스안에 순수 가상 함수를 포함하고 있는 클래스를 가상 클래스라고 부른다.

가상함수를 만들었어도 필요없으면 안써도되지만, 순수 가상함수를 만들었으면 꼭 가상함수를 사용해야함.

반응형