본문 바로가기

C++문법 공부

가상함수 Virtual과 오버라이딩

1. 다형성이란? 

 

가상함수 Virtual Functions 은 객체지향 프로그래밍에서 다형성을 완성시켜주는 개념입니다.

다형성(polymorphism)이란 한자의 뜻을 풀자면 형태가 다양하다는 뜻이 되겠습니다.

여기서 형태는 당연히 자료형, 즉 객체가 되겠구요

 

객체 지향 프로그래밍의 4가지 특징ㅣ추상화, 상속, 다형성, 캡슐화 - (codestates.com)

 

객체 지향 프로그래밍의 4가지 특징ㅣ추상화, 상속, 다형성, 캡슐화 -

객체 지향 프로그래밍은 객체의 유기적인 협력과 결합으로 파악하고자 하는 컴퓨터 프로그래밍의 패러다임을 의미합니다. 객체 지향 프로그래밍의 기본적인 개념과 그 설계를 바르게 하기 위

www.codestates.com

국내에서 객체 지향의 개념을 이것보다 잘 정리해 놓은사이트는 찾기 힘들어서 링크를 첨부 했습니다.

 

제 나름대로 다형성의 개념에 대해 정리를 해보자면

컴퓨터가 자료를 처리하여 어떤 동작을 수행하려면 메모리에 기억된 데이터 자체와 그 데이터를 어떤식으로 활용할지에 대한 설명서, 즉 자료형이 필요합니다

 

객체지향 프로그래밍에서 자료형은 곧 객체를 의미하게 되는데, 맴버 함수 구현에 있어서 어차피 함수가 해야 하는 동작이 동일하다면 비슷한 속성을 가진 객체끼리 묶어서 (예제에서는 부모 객체를 사용하여 자식 객체들을 한꺼번에 묶었습니다)

묶인 객체들은 자료형에 상관없이 함수가 동작하도록 만들어 버리는 것이죠.

 

메인보드에 쓰이는 볼트나사로 예를 들어 보겠습니다.

메인보드에 [ 나사 구멍1, 나사 구멍2, ... 6 ] 이 있다고 가정 하겠습니다 나사가 하는 일은 어차피 고정한 물체가 움직이지 않도록 고정하는 일입니다. 구멍의 위치가 다르겠지만 동작 자체는 전부 동일 하죠. 이럴 때 각각의 나사구멍이 '메인보드 나사' 라는 클래스를 상속받는다면, 그리고 그 부모객체를 매개변수화해서 사용한다면 하나의 메소드가 여러가지 타입을 매개변수로 받아서 같은 동작을 수행할 수 있게 된다는 겁니다.

 

이렇게 타입에 최대한 구애받지 않고 코드를 쓰는 프로그래머가 동작에 집중할 수 있도록 설계하는 것으로 다형성을 고려해서 프로그래밍 했다 라고 할 수 있겠네요.

 

비유를 하나 더 설명하자면 구급약통에 약을 분류 할때, 먹는약, 바르는약, 붙이는약

이렇게 분류해 놓는다면 타입에 상관없이 '먹기', '바르기', '붙이기' 로 모든 약을 처리 할 수 있게 되겠지요.

 

 

2. Virtual 이라는 개념에 대해서

영한사전 검색 papago

virtual의 단어를 사전상으로 검색 해보면

허구, 허상. 즉 가짜이지만 진짜와 같은 역할을 하고 있다.

이러한 뉘앙스를 띄고 있습니다.

 

다형성을 고려한 프로그래밍

Virtual을 설명하기 전에 다형성에 대해 이야기 한 이유는 Virtual 이라는 개념이 다형성을 구현하면서 생기는 문제를 해결하는데 중요한 역할을 해주기 때문입니다.

다형성을 챙기려 할 때 필연적으로 같은 이름으로 동작을 다르게 설정해야 하는 경우가 생기기 마련입니다.

위에 예로 든 그림을 보면 분명히 '먹어라' 라는 함수로 음식속성을 상속받은 객체를 모두 다룰 수가 있지만

이렇게 모든것을 똑같이 먹다가는 닭가슴살을 먹다가 목에 걸려 죽을 위기에 처하는 상황이 생기게 될 수 있습니다.

닭가슴살을 먹을 때는 퍽퍽한 식감을 피할 수 있도록 가능하면 잘게 잘라서 꼭꼭 씹어 먹으면 좋을 것입니다.

브로콜리를 먹을 때에는 영양소 파괴를 피하기 위해 스팀으로 살짝 익혀서 브로콜리만 단독으로 먹기 보다는 먹기 편하게 해주는 음식과 곁들이는것이 좋을 것입니다.

이렇게 같은 먹어라이지만 동작을 다르게 해주지 않으면 문제가 생기는 경우가 있을 수 있습니다.

이 문제를 해결하는 것이 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; }
};

void main() {
	Derived d, * pDer;
	pDer = &d;	//객체 d를 가리킨다.
	pDer->f();	//Derived의 맴버 f() 호출

	Base* pBase;
	pBase = pDer;	//업 캐스팅
	pBase->f(); //Base의 맴버 f() 호출
}

main 함수부터 살펴 보겠습니다.

pDer는 선언부터 사용 까지 Derived의 타입으로 사용 되었습니다.

f()를 호출 했더니 Derived의 맴버 f()가 호출 되었네요

 

pBase는 선언은 Base 타입으로 되었지만 업캐스팅을 통해 Derived타입으로 변경이 되었습니다.

그런다음 pBase의 f()를 호출했더니 이번엔 Base의 맴버 f()가 호출 되네요. 상속받았겠다. public 접근이겠다. 접근하는데 전혀 문제가 없는 상태이긴 하죠

전자와 후자, 모두  Derived 타입이지만 f()를 호출 했을 때 호출되는 것이 제각각입니다. 제가 만약 Derived의 맴버 f()를 호출하고자 했다면 어떤 방법을 써야 했을까요? 이름이 똑같기 때문에 사실상 구분할 수 있는 방법이 없습니다.

 

 

#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; }
};

void main() {
	Derived d, * pDer;
	pDer = &d;	//객체 d를 가리킨다.
	pDer->f();	//Derived의 맴버 f() 호출

	Base* pBase;
	pBase = pDer;	//업 캐스팅
	pBase->f(); //동적 바인딩 발생 Derived::f() 실행
}

 

위의 예제 코드에서 달라진 것이라고는 부모 클래스인 Base 객체 맴버인 f()함수를 구현할때 virtual이라는 예약어를 붙여준 것 뿐입니다.

 

그랬더니 제가 원하는 Derived의 맴버 f()만 호출 되면서 문제가 해결되었습니다.

Virtual이 어떻게 문제를 해결했는지 보이시나요 ?

 

d라는 객체에는 본인의 맴버 f()가 있고, 부모 객체로 부터 상속받은 f()가 또 있습니다. 2개의 f()가 있는 셈이지요.

하지만 virtual을 통해 Derived의 맴버가 Base의 맴버를 무시 하도록 오버라이딩 되었기 때문에 Derived의 맴버만 사용된 것입니다.

 

여기서 오버라이딩이라는 단어가 쓰였습니다.

override 단어 papago

단어의 뜻을 보면 딱 어떤 느낌인지 감이 오죠. ride 자체가 올라탄다는 뜻이기 때문에 연상하기도 편합니다.

 

 

예제를 하나만 타이핑 해 보았습니다.

#include <iostream>
using namespace std;

class Shape {
public:
	virtual void draw(){
		cout << "Shape을 그린다." << endl;
	}	

};

class Circle :public Shape {
	virtual void draw() {
		cout << "Circle을 그린다." << endl;
	}
};

class Rect :public Shape {
	virtual void draw() {
		cout << "Rect를 그린다." << endl;
	}
};

class Tr :public Shape {
	virtual void draw() {
		cout << "Tr을 그린다." << endl;
	}
};

void paint(Shape* p) {
	p->draw();
}

int main()
{
	paint(new Circle);
	paint(new Rect);
	paint(new Tr);
}

 

먼저 살펴본 예제와 똑같이 paint라는 함수가 Shape 타입의 포인터를 받도록 하게 했고, 형변환이 일어나도록 했습니다.

Shape* p = new Circle 의 형변환이 일어났죠.

virtual 이 없었다면 맨 처음 예제와 마찬가지로 부모클래스의 draw() 함수

그러니까 'Shape를 그린다' 만 3개 출력되었으리라 예상할 수 있습니다. (한번 실제로 테스트 해보길 권장합니다.)

하지만 부모클래스의 draw 함수가 

virtual void draw(){ cout << "Shape을 그린다." << endl; }

위와 같이 virtual 예약어와 함께 사용되었죠?

그로 인해 오버라이딩이 가능해지고, 각각의 파생 클래스인 Circle, Rect, Tr의 맴버 Circle::draw(), Rect::draw(), Tr::draw() 가 호출 된겁니다.

각각 파생객체의 맴버 함수들도 virtual로 선언되어있죠?

그러면 그다음 파생될 객체의 함수도 오버라이딩이 가능하겠구나 라고 예상해 볼 수 있겠습니다.

그런데 사실 파생 객체에서는 virtual 선언을 하지 않아도 됩니다. virtual 예약어까지 자동으로 상속되거든요.

생략이 가능하기 때문에 해도 되고 안해도 되는겁니다. 그런데 안쓰면 헷갈리니까 써줍시다.

virtual의 목적

부모 클래스에 가상함수를 만들게 되면서 파생되는 자식클래스들이 각각 자신의 목적에 맞는 함수로 가상함수를 재정의 하도록 하는데에 목적이 있습니다. 즉, 함수 인터페이스를 제공하는거죠.

위의 음식을 예로 들어 보면 음식이라는 부모 클래스가 샐러드, 브로콜리, 닭가슴살이라는 자식클래스에게 이렇게 말하는 겁니다.

"앞으로 태어날 아이들아 ! 너희를 사용해서 먹는다 라는 동작을 구현할 수 있어야해 ! "

"구체적으로 어떻게 구현할 지는 너희들한테 맡긴다."

 

여기서 자신만의 함수를 만들어서 부모클래스의 함수를 덮어 씌우는 것을 오버라이딩이라고 합니다.

, 오버라이딩을 가능하게 도와주는 도구가 virtual 인 것입니다.

다형성을 실현하는 도구이죠.

 

Virtual 함수를 상속 받고, 자식 클래스에서 다시 정의하면 자식클래스의 객체가 사용 될 때,

이름이 똑같더라도 무조건 다시정의한(오버라이딩한) 함수가 사용됩니다.

이를 동적 바인딩이라고 합니다. 

동적 바인딩은 Run-Time Binding, 혹은 Late Binding이라고도 부릅니다.

원래 호출해야할 부모 함수를 버리고, 오버라이딩된 함수를 찾아 실행 하는 것이기 때문에 저런 이름이 붙은 것이지요.

 

동적 바인딩은 언제 발생하는가?

동적바인딩은 부모클래스의 포인터로 자식클래스를 참조하게 되었을 때, 가상함수를 호출하면 일어난다.

동적바인딩이 발생하는 경우는 다음과 같다.

  • 부모 클래스 내의 맴버 함수가 가상함수를 호출 할 때
  • 자식 클래스 내의 맴버 함수가 가상함수를 호출 할 때
  • main과 같은 외부 함수에서 기본 클래스의 포인터로 가상함수 호출
  • 다른 클래스에서 가상함수 호출

동적바인딩 조건으로 한가지만 체크하고 넘어가겠습니다.

#include <iostream>
using namespace std;

class Food {
public:
	void Eat() {
		Chew();
	}
	virtual void Chew() {
		cout << "음식을 씹어요." << endl;
	}
};

class Chicken:public Food {
public:
	void Chew() {
		cout << "치킨을 씹어요." << endl;
	}
};

int main() {
	Food* pFood = new Chicken();
	pFood->Eat();
	delete pFood;
}

치킨 클래스가 푸드 클래스를 상속 했습니다.

푸드 클래스에는 Chew 가상함수가 있고, Eat 맴버 함수가 있습니다.

Eat 맴버함수가 Chew 가상함수를 호출하고 있습니다.

 

main 함수에서 Food 타입의 포인터 pFood 에 치킨타입으로 형변환을 시키고 Eat을 호출 합니다.

결과는 동적바인딩이 발생합니다.

즉, 자식클래스가 부모클래스 맴버 함수를 호출하는 방법으로 직접 가상함수를 호출하지 않고 간접적으로 호출하더라도 동적바인딩이 발생한다는것을 볼 수 있습니다.

 

 

3. 오버라이딩의 조건

오버라이딩의 조건은 C언어에서 함수포인터의 형태를 지정할때의 조건과 아주 흡사합니다.

그 함수의 원형(Prototype)만 같으면 됩니다.

  1. 함수의 이름
  2. 매개변수의 성질 ( 매개변수의 수와 각 데이터 타입*자료형 )
  3. 리턴값의 타입

 

4. 정적 바인딩을 하고자 할 경우

#include <iostream>
using namespace std;

class Food {
public:
	virtual void eat()
	{
		cout << "--EatFood--";
	}
};

class Chicken : public Food {
public:
	int x;
	virtual void eat() {
		Food::eat();	//스코프 연산자로 기본 클래스의 eat()를 저격 호출
		cout << "--EatChicken--" << endl;
	}
};

int main() {
	Chicken chicken;
	Food* pFood = &chicken;

	pFood->eat();	//동적 바인딩 발생. eat() 가 virtual 이므로 자식 클래스의 eat()이 호출 된다.
	pFood->Food::eat();	//정적 바인딩 발생. 스코프 연산자로 부모클래스의 eat()을 저격해서 호출했다.
}

정적 바인딩을 원할 경우 스코프 연산자 ' :: '를 활용해서 저격할 수 있다.

'C++문법 공부' 카테고리의 다른 글

함수 템플릿  (0) 2023.07.03
소멸자의 virtual 선언  (0) 2023.06.30
포함  (0) 2023.06.27
new 연산자  (0) 2023.06.26
typedef를 클래스 안에서만 맴버로 사용하기  (0) 2023.06.25