C++/C++ : Study

다형성(5) - 순수 가상 함수와 추상 클래스

더블유제이플로어 2025. 5. 29. 19:22

Pure Virtual Functions and Abstract Classes

◆  추상 클래스 (Abstract Class)

   ●  객체를 생성할 수 없는 클래스
   ●  상속 계층 구조에서 기본 클래스로 사용됨
   ●  아주 일반적이어서 객체를 생성하기엔 맞지 않다
        ☞   Entity , Account (어떤 객체인지 확인 어려움)

◆  구상 클래스 (Concrete Class)

   ●  객체를 생성할 수 있는 클래스
   ●  모든 멤버 함수가 구현되어 있어야 한다
        ☞   지금까지 예시로 든 모든 클래스는 구상 클래스

virtual 함수를 만들어서 오버라이딩을 했을 때 어떤 명령문에서 어떤 함수가 호출되는지
설계할 때 알아두어야 한다.
어떻게 동작하는지 정확하게 아는 것이 중요하다.

순수 가상 함수에 대해 알아보기 전에 추상 클래스에 대해 알아보자.

추상 클래스는 객체를 생성할 수 없는 클래스이다.
객체를 생성할 수 없는데 왜 클래스를 만들까?
필요한 경우가 있다.
추상 클래스는 주소 기본 클래스로 사용된다.
의미적으로 생각하면 일반적이어서 객체를 생성하기 어려운 클래스를 말한다.

예시로 주로 Entity 클래스로 자주 코드를 작성해 왔다.
Entity 클래스는 의미적으로 게임을 제작할 때
게임 안에 Player, Monster, Boss , NPC 등 여러 가지 캐릭터들이 있다.
그 모든 객체들이 다 위치값을 가지고 있다.
그리고 움직이게 할 수도 있다.
그러면 공통점은 게임 안에 만든 요소들은 다 위치값을 가지고 있고,
움직일 수 있고(Move), 위치 값을 표현(MovePosition) 할 수 있다.

실제로 게임을 만들 때에는 Entity 객체를 만들 일이 있을까?
없다. Player , Monster , Boss , NPC 객체들만 있다.
Entity라는 것은 공통 속성을 뽑아 쉽게 관리하기 위 만든 클래스 타입이다.
게임상에 표시될 일이 없다.
Entity로 객체를 못 만들게 하고 다른 용도로 사용한다.
그것이 추상 클래스이다.

실제로 객체를 만들면 안되는 클래스 타입을 만드는 방법이 추상 클래스로 만드는 것이다.

반대의 의미가 구상 클래스이다. 
구상 클래스이가 추상 클래스가 아닌 모든 클래스이다.
지금까지는 구상 클래스로만 배웠고
이번에 추상 클래스를 만드는 방법에 대해 알아볼 예정이다.

구상 클래스여야지만 객체를 만들 수 있다.
그리고 모든 멤버 함수가 구현되어 있어야 하는 조건이 있다.

예를 들어서
Player 클래스에 PrintPosition()을 구현해 놓고 인라인 하여 함수를 선언할 경우

class Player : public Entity {
private:
	int hp;
public:
	Player(int x, int y, int hp)
		:Entity{x,y},hp{hp} { }
	virtual void Move(int dx, int dy)
	{
		x += dx * 2;
		y += dy * 2;
	}
	void MovePlayer(int dx, int dy)
	{
		x += dx * 2;
		y += dy * 2;
	}
	virtual ~Player()
	{
		cout << "Player Destructor" << endl;
	}
	virtual void PrintPosition() const override;
};

void Player::PrintPosition() const // override 는 한번만 선언하면 된다.
{
	cout << "Player: " << x << ", " << y << endl;
}

이런 식으로 구현할 수 있다.

void Player::PrintPosition() const을 구현하지 않을 경우에는 ERROR 가 발생한다.

다시 좀 복잡해져서 코드를 수정한다. 왜 복잡해졌을까?

* 왜 복잡해졌을까? (AI claude)


1. 순수 가상 함수 vs 일반 가상 함수

처음에는 단순히 "가상 함수 구현을 안하면 링크 에러남"이라고 설명하려고 하였으나,
실제로는 PrintPosition() 이 순수 가상함수(=0) 인지 일반 가상 함수인지에 따라 결과가 완전히 달라진다.

// Entity 클래스에서 PrintPosition()이 이렇게 되어 있다면:

// Case 1: 순수 가상함수
virtual void PrintPosition() const = 0;  // 반드시 구현해야 함

// Case 2: 일반 가상함수  
virtual void PrintPosition() const { 
    cout << "Entity: " << x << ", " << y << endl; 
}  // 구현 안 해도 됨, 부모 것 사용

2. 링크 에러가 발생하는 진짜 이유

만약 Entity 에서 PrintPoistion()을 순수 가상 함수로 선언했다면 : 컴파일 에러 (Player 클래스가 추상클래스가 된다.)
만약 Entity 에서 일반 가상함수로 선언했는데 Player에서 선언만 하고 구현 안 했다면 : 링크 에러


class Entity {
protected:
	int x;
	int y;
public:
	Entity(int x, int y)
		:x{ x }, y{ y } { }
	 ~Entity()
	{
		cout << "Entity Destructor" << endl;
	}
	virtual void Move(int dx, int dy) {
		x += dx;
		y += dy;
	}
	virtual void PrintPosition() const 
	{
		cout << "Entity :" << x << ", " << y << endl;
	}
	void NewFun(); // 몸체 구현 안함
};

int main()
{
	Entity e{ 1,1 };
	e.NewFun(); // 링크 에러 발생
}

이렇게 선언하면 링크에러가 제대로 확인할 수 있다.
당연히 함수들은 본체를 가지고 있어야 한다.
본체를 만들지 않으면 추상클래스가 된다. (밑에를 참고할 것)
virtual void function() = 0; 일 경우 순수 가상함수로 선언할 수 있다.


* 왜 링크 에러가 발생하였는가? (AI claude)

1. 컴파일 단계 : 성공

void NewFun(); 선언이 있으니 컴파일러는 "함수가 어딘가에 구현되어 있겠다"라고 인식하여 넘어감. 문법적으로 문제없다.

2. 링크 단계 : 실패

링커가 e.NewFun() 호출을 위해 실제 함수 구현체를 찾으려고 한다.
어디에도 Entity::NewFun()의 구현이 없다.
"unresolved external symbol" 에러가 발생한다.

3. 교수님이 헷갈렸던 이유

처음에 PrintPosition()을 예시로 들려고 하였으나, Entity에서 이미 구현이 되어있었다.
그래서 Player에서 virtual void PrintPosition const override; 만 선언하고 구현을 안 해도 링크 에러가 안 난다.
부모 클래스 구현을 사용하기 때문이다.

결론

가상함수든 일반함수든, 선언만 하고 구현 안 하면 -> 링크 에러

순수 가상함수(=0)를 구현 안하면 -> 컴파일 에러 (추상클래스가 된다.)



◆  추상 클래스는 하나 이상의 "순수 가상 함수"를 갖는다.
◆  즉, "순수 가상 함수"가 있는 클래스는 추상 클래스이다.

   ●  멤버 함수의 선언 뒤에 "=0"을 붙이면 순수 가상 함수가 된다.

virtual void function() = 0;

virtual void function() = 0; 함수는 몸체가 없는 함수이다.
몸체가 없는 함수를 순수 가상 함수가 된다.

그리고 순수 가상 함수가 있는 클래스는 추상클래스가 된다.

class Entity {
protected:
	int x;
	int y;
public:
	Entity(int x, int y)
		:x{ x }, y{ y } {
	}
	~Entity()
	{
		cout << "Entity Destructor" << endl;
	}
	virtual void Move(int dx, int dy) = 0; // Entity 추상 클래스가 됨
	virtual void PrintPosition() const
	{
		cout << "Entity :" << x << ", " << y << endl;
	}
};
int main()
{
	Entity e{ 1,1 }; // 컴파일 에러
}

추상 클래스는 객체를 만들 수 없기 때문에 Entity e 선언 시 e 밑에 빨간 줄(컴파일 에러)이 뜬다.

Entity::virtual void Move(int dx, int dy) = 0; 선언하면 뭐가 좋을까?


* AI claude

추상 클래스로 만들었을 때 장점

1. 인스턴스 생성 방지

Entity e{1, 1};  // 컴파일 에러! 추상클래스는 객체 생성 불가

. Entity 자체는 "개념적인 틀"이지 실제로 존재하는 구체적인 객체가 아니다. Player, Monster , NPC 등의 기반이 되는 개념일 뿐이다.

2. 강제적인 구현 보장

class Player : public Entity {
private:
	int hp;
public:
	Player(int x, int y, int hp)
		:Entity{x,y},hp{hp} { }
	// Move() 를 반드시 구현해야 함. 안그러면 Player 도 추상 클래스가 된다.
	virtual void Move(int dx, int dy) override
	{
		x += dx * 2; // 플레이어만의 이동 방식
		y += dy * 2;
	}
	virtual ~Player()
	{
		cout << "Player Destructor" << endl;
	}
	virtual void PrintPosition() const override // ERROR 발생
	{
		cout << "Player: " << x << ", " << y << endl;
	}
};

class Monster : public Entity {
private:
	int hp;
public:
	Monster(int x, int y, int hp)
		:Entity{ x,y }, hp{ hp } {
	}
	virtual ~Monster()
	{
		cout << "Monster Destructor" << endl;
	}
    	virtual void Move(int dx, int dy) override 
	{
		x += dx / 2; // 몬스터만의 이동 방식
		y += dy / 2;
	}
};

3. 설계 의도 명확화

"Entity는 직접 사용하는 클래스가 아니라, 상속받아서 사용하라고 만든 클래스야."라는 설계 의도를 코드로 표현하였다.

4. 다형성 활용

vector<Entity*> entities;
entities.push_back(new Player(0,0,100));
entities.push_back(new Monster(10,10));

for(auto* entity : entities)
{
	entity->Move(1,1); // 각자의 Move() 함수가 호출됨
}

5. 유지보수성 향상

새로운 Entity 타입을 추가할 때, Move() 함수를 구현하지 않을 수 없다. 컴파일러가 강제로 구현하게 만든다.

요약 : "이 클래스는 직접 쓰지 말고, 상속 받아서 필수 기능들을 구현한 후에 써라." 강력한 가이드라인 제공하는 것.



중요한 Part이다.

◆  "순수 가상 함수"가 있는 클래스는 추상 클래스이다.

   ●  유도 클래스들은 반드시 기본 클래스의 순수 가상함수를 오버라이드 해야 한다.
        ☞   오버라이드 하지 않는 경우, 유도 클래스도 추상 클래스로 간주된다.
        ☞   즉, 유도 클래스에서 특정 함수 구현을 "강제"로 하는 의미를 가진다.

◆  사용 목적

   ●  기본 클래스에서의 구현이 적절하지 않은 경우
   ●  유도 클래스에서는 반드시 구현해야 함을 명시하기 위해

   ●    ex) 모든 Entity는 x , y 좌표를 가지고 있고 이동이 가능함. 
         그러나 실제로 게임 내에서 표현되는 객체가 되러면 어떤 로직으로 이동이 가능한지 구체적 기능이 필요하다.
        ☞   그렇기에 Player 객체, Entity 객체가 이동이 가능하려면, Move() 함수를 반드시 오버라이딩 해야 하기에
강제로 하기 위해 Move()를 순수 가상함수로 구현한다.

순수 가상 함수가 있는 클래스는 추상클래스가 된다.
객체를 생성하지 못하게 된다.
이때 유도 클래스들이 반드시 기본 클래스의 순수 가상 함수를 오버라이드 해야 한다.
오버라이드 하지 않으면 유도 클래스도 추상 클래스가 된다.

코드로 설명할 경우 Entity::virtual void Move(int, int) 함수는 순수 가상 함수이다.

만일 Player 클래스에서 Move() 함수를 오버라이드 하지 않을 경우,
Entity 객체 뿐만아니라, Player 객체 또한 만들지 못한다.
이유는 클래스의 객체를 만들기 위해서는 클래스의 모든 멤버들이 구현되어있어야 한다

근데 Move() 함수는 구현이 안되어 있다.
Entity::virtual void Move(int , int) = 0;
함수 정의만 되어있고 몸체는 없다.

몸체는 오버라이딩해서 만들어줘야 한다.
Player::virtual void Move(int,int) override

그렇게 해야 Player 객체를 만들 수 있게 된다.

어떤 의미이냐면 앞으로 Entity 를 상속받아서 만들 Player 클래스들은
Move() 함수를 반드시 override 를 해야 한다.
Move() 함수 구현을 안하면 객체를 만들 수 없기 때문이다.

그렇게 유도 클래스에서 특정 함수구현을 강제하는 의미를 가진다.

사용되는 곳은 기본 클래스에서 구현이 적절치 않는 경우이다.
Entity는 실제로 게임에 필요한 클래스가 아니기 때문에
Move() 함수의 몸체가 Entity 클래스에 있을 필요가 없다.
Player, Monster, NPC 움직이는걸 각각 다르게 정의해야 하기 때문이다.
Entity라는 것은 실제 게임에서 사용하는 객체가 아니고 추상적이기 때문에
움직임 등은 정의할 수 없다.
하지만 Entity 를 상속하는 클래스들에서는 움직임을 정의해야만 한다.

순수 가상 함수를 정의를 해서
유도클래스에 있는 특정 함수 구현을 강제(명시)하는 의미이다.

의문점이 들 수 있다.
Entity::virtual void Move(int , int) {}; 이렇게 구현해도 되지 않나?
이렇게 구현할 수 있다.
하지만 Entity::virtual void Move(int , int) =0; 선언과의 차이점은
Entity 객체를 만들 수 있다.
Entity 객체를 만들지 못하게 만들 경우에는 =0; 이렇게 선언해야 한다.
강력한 제약 조건을 두는 기능이라고 생각하면 된다.
코드 적용할때도 강력한 제약 조건을 두는 게 좋다.

private 변수를 사용하는 이유랑 비슷하다.
private 변수도 public 변수로 바뀌어도 괜찮다.
private 변수로 선언하는게 더 안전하고, 코드의 가이드라인을 정해주기 때문에
private 변수 선언하는 듯이 =0; 선언하는 게 좋다.


◆  순수 가상 함수와 추상 클래스 예시

class Shape { // Abstract
private:
	// Member variables
public:
	virtual void draw() = 0; // 순수 가상 함수들이 있으니, 추상 클래스이다.
	virtual void rotate() = 0;
	virtual ~Shape();
};
Q. 순수 가상 소멸자가 필요할 때도 있을까?

class Circle : public Shape {
private:
	// Member variables for circle
public:
	virtual void draw() override {
		// implemetation for circle
	}
	virtual void rotate() override {
		// implementation for circle
	}
	virtual ~Circle();
    ...
};
가상 함수들을 모두 오버라이드하여 구현하였으니 구상 클래스가 됨.

   ●  추상 클래스는 객체를 생성할 수 없다

Shape shape; // ERROR!
Shape *ptr = new Shape(); // ERROR!

   ●  하지만 여전히 추상(기본) 클래스의 포인터/참조자를 사용해 오버라이딩된 함수를 동적 바인딩 할 수 있다.

Shape *ptr = new Circle();
ptr->draw();
ptr->rotate();

추상 클래스는 타입은 선언할 수 있고, 객체를 선언할 수 없다는 것을 기억해 두자.


Polymorphism

◆  다형성과 동적 바인딩 : 기본 동작인 정적 바인딩은 컴파일 시 타입을 기준으로 호출 함수를 결정하지만, 동적 바인딩을 하게 되면 런타임 시 실제 메모리에 저장된 타입을 기준으로 호출 함수를 결정
◆  가상 함수 : 컴파일러에게 동적 바인딩을 하려고 알려주기 위해서는 기본 클래스의 함수를 virtual 키워드를 통해 가상 함수로 만들어주어야 한다. 이런 경우, 소멸자도 항상 가상 함수로 선언 필요하다.
◆  기본 클래스의 포인터 / 참조자 : 동적 바인딩을 위해서는 기본 클래스의 포인터 / 참조자로도 유도 클래스를 가리키고 함수를 호출 
◆  override / final 지정자 : override는 가상함수의 오버라이딩을 명시하여 실수 방지, final 은 더 이상 상속 / 오버라이드를 하지 못하도록 하는 키워드
◆  순수 가상 함수와 추상 클래스 : 순수 가상함수가 있다면 추상 클래스이고, 이런 클래스는 객체를 생성할 수 없다. 유도 클래스에서 순수 가상 함수를 오버라이딩 해야 객체 생성 가능하다.
◆  추상 클래스와 인터페이스

순수 가상함수가 하나라도 있으면 그 클래스는 추상 클래스가 되고, 추상 클래스는 객체를 생성할 수 없다. 

객체를 생성하려면 추상 클래스를 상속 받은 유도 클래스에서 순수 가상 함수를 override 해야 객체를 생성할 수 있다.


https://youtu.be/u8A7pKEfy0M?si=4H04xYZvvYeExoR9

오래 걸렸으나, 완독하였고 바로 다음 강의 넘어가자.