1. 객체 지향 프로그래밍 (OOP)
1.1. 이론
1.1.1. 객체 지향 프로그래밍이란?
객체 지향 프로그래밍(OOP)은 프로그램을 수많은 '객체(object)'라는 기본 단위로 나누고, 이 객체들의 상호작용으로 서술하는 방식의 프로그래밍 패러다임입니다. 이는 현실 세계의 사물이나 개념을 그대로 컴퓨터 속으로 옮겨와 모델링하는 것과 유사하여, 복잡한 시스템을 보다 직관적으로 이해하고 설계할 수 있게 돕습니다.
1.1.2. OOP의 4대 핵심 원칙
OOP를 지탱하는 네 개의 기둥은 캡슐화, 상속, 다형성, 추상화입니다.
- 캡슐화 (Encapsulation)
- 개념: 데이터(속성)와 그 데이터를 처리하는 함수(메서드)를 하나의 '객체'라는 캡슐로 묶고, 정보 은닉(Information Hiding)을 통해 객체의 내부 구현을 외부로부터 숨기는 것입니다.
- C++ 구현:
class내부에public,protected,private접근 지정자를 사용합니다.private멤버는 외부에서 직접 접근할 수 없으며, 오직public으로 공개된 메서드를 통해서만 상호작용할 수 있습니다. - 내부 동작: 캡슐화는 컴파일러에 의해 강제되는 문법 규칙입니다. 접근 권한이 없는 멤버에 접근하려는 코드는 컴파일 시점에 오류로 처리되며, 런타임 성능 저하는 없습니다.
- 상속 (Inheritance)
- 개념: 기존 클래스(부모)의 속성과 메서드를 새로운 클래스(자식)가 물려받는 기능입니다. 코드의 중복을 줄이고, 클래스 간의 'is-a' 관계를 명확하게 표현합니다. (예:
Playeris aCharacter) - C++ 구현:
class Derived : public Base { ... };구문을 사용합니다. - 내부 동작: 자식 클래스의 객체는 메모리 상에서 부모 클래스의 멤버 변수들을 먼저 포함하고, 그 뒤에 자식 클래스 자신의 멤버 변수들이 추가되는 형태로 구성됩니다. 이 때문에 자식 클래스 포인터는 부모 클래스 포인터로 안전하게 형 변환(Up-casting)될 수 있습니다.
- 개념: 기존 클래스(부모)의 속성과 메서드를 새로운 클래스(자식)가 물려받는 기능입니다. 코드의 중복을 줄이고, 클래스 간의 'is-a' 관계를 명확하게 표현합니다. (예:
- 다형성 (Polymorphism)
- 개념: '여러 가지 형태'를 가진다는 의미로, 동일한 인터페이스가 객체의 실제 타입에 따라 다른 방식으로 동작하는 성질입니다.
- C++ 구현: 컴파일 시점에 결정되는 정적 다형성(함수 오버로딩, 템플릿)과, 런타임에 결정되는 동적 다형성(가상 함수)이 있습니다. 동적 다형성은 OOP의 핵심적인 유연성을 제공합니다.
- 내부 동작: 동적 다형성은 가상 함수 테이블(v-table)과 가상 함수 포인터(v-ptr)를 통해 구현됩니다. (2장에서 상세히 설명)
- 추상화 (Abstraction)
- 개념: 복잡한 내부 구현은 숨기고, 사용자에게는 꼭 필요한 핵심 기능(인터페이스)만을 노출하는 것입니다.
- C++ 구현: 접근 지정자를 통한 기본적인 추상화와, 순수 가상 함수를 포함하는 추상 클래스(Abstract Class)를 통해 인터페이스를 명세함으로써 강력한 추상화를 구현합니다.
- 내부 동작: 추상 클래스는 인스턴스화될 수 없도록 컴파일러가 막아주며, 이를 통해 '설계'와 '구현'을 분리하도록 강제합니다.
1.2. 실습 코드
#include <iostream>
#include <string>
// Character 클래스: 게임 캐릭터의 공통 속성과 기능을 정의
// 캡슐화: hp, power 등은 private으로 보호
// 추상화: 사용자는 Attack, TakeDamage 등 공개된 인터페이스로만 캐릭터와 상호작용
class Character {
private:
int hp;
int power;
protected:
std::string name;
public:
Character(const std::string& name, int hp, int power)
: name(name), hp(hp), power(power) {
std::cout << name << " 등장! (HP: " << hp << ", Power: " << power << ")" << std::endl;
}
// 가상 소멸자. 2장에서 자세히 다룹니다.
virtual ~Character() {
std::cout << name << " 퇴장!" << std::endl;
}
void Attack(Character& target) {
std::cout << this->name << "이(가) " << target.name << "을(를) 공격!" << std::endl;
target.TakeDamage(this->power);
}
void TakeDamage(int damage) {
this->hp -= damage;
if (this->hp < 0) this->hp = 0;
std::cout << this->name << "의 현재 HP: " << this->hp << std::endl;
}
bool IsAlive() const {
return this->hp > 0;
}
};
// Player 클래스: Character를 상속받아 확장
// 상속: Character의 모든 public, protected 멤버를 물려받음
class Player : public Character {
private:
int level;
public:
Player(const std::string& name, int hp, int power)
: Character(name, hp, power), level(1) {}
void LevelUp() {
this->level++;
std::cout << this->name << " 레벨 업! 현재 레벨: " << this->level << std::endl;
}
};
// Monster 클래스: Character를 상속받아 확장
class Monster : public Character {
private:
std::string monsterType;
public:
Monster(const std::string& name, int hp, int power, const std::string& type)
: Character(name, hp, power), monsterType(type) {}
void Roar() const {
std::cout << this->name << " (" << this->monsterType << ")이(가) 울부짖는다!" << std::endl;
}
};
int main() {
Player player("용사", 100, 15);
Monster monster("고블린", 30, 5, "소형");
player.Attack(monster);
if (monster.IsAlive()) {
monster.Roar();
monster.Attack(player);
}
return 0;
}
2. 다형성과 가상 함수 (Polymorphism & Virtual Functions)
2.1. 이론
동적 다형성은 OOP의 꽃이라 불리며, C++에서는 가상 함수(Virtual Function)를 통해 구현됩니다.
2.1.1. 가상 함수의 동작 원리: v-table과 v-ptr
- 개념: 부모 클래스 포인터나 참조를 통해 자식 클래스 객체를 가리킬 때, 함수를 호출하면 포인터의 타입이 아닌 실제 객체의 타입에 정의된 함수가 호출되도록 하는 메커니즘입니다.
virtual키워드: 부모 클래스에서 향후 자식 클래스가 재정의(override)할 가능성이 있는 함수 앞에virtual키워드를 붙입니다.- 내부 구조:
- 가상 함수 테이블 (v-table): 컴파일러는
virtual함수를 하나라도 가진 클래스에 대해, 가상 함수들의 주소를 저장하는 함수 포인터 배열인 'v-table'을 생성합니다. 이 테이블은 해당 클래스의 모든 객체들이 공유하는 정적 데이터입니다. - 가상 함수 포인터 (v-ptr): 컴파일러는 해당 클래스의 객체가 생성될 때, 객체의 메모리 공간 맨 앞 또는 맨 뒤에 숨겨진 포인터인 'v-ptr'을 추가합니다. 이 v-ptr은 객체가 속한 클래스의 v-table을 가리킵니다. 객체마다 v-ptr을 하나씩 가지며, 생성자에서 초기화됩니다.
- 가상 함수 테이블 (v-table): 컴파일러는
- 호출 과정:
Character* ptr = new Player();와 같이 부모 포인터로 자식 객체를 가리킵니다.ptr->Attack();처럼 가상 함수를 호출합니다.- 런타임에 프로그램은
ptr이 가리키는 객체(Player 객체)의 v-ptr을 따라갑니다. - v-ptr이 가리키는 v-table(
Player의 v-table)에 접근합니다. - v-table에서
Attack함수에 해당하는 주소를 찾아 그 함수를 호출합니다. (Player의Attack이 호출됨)
- 오버헤드: 이 과정은 일반 함수 호출(주소를 컴파일 타임에 확정)에 비해 2번의 포인터 역참조(v-ptr, v-table)가 추가되므로 약간의 런타임 성능 저하가 발생합니다. 하지만 현대 CPU에서는 그 차이가 미미하며, 다형성이 주는 설계의 유연함이 훨씬 큰 이점을 제공합니다.
2.1.2. 가상 소멸자 (Virtual Destructor)
- 문제점: 부모 클래스의 포인터로 자식 객체를
delete할 때, 만약 부모의 소멸자가virtual이 아니라면 부모의 소멸자만 호출됩니다. 이 경우 자식 클래스에서 할당한 리소스가 해제되지 않아 메모리 누수가 발생합니다. - 해결책: 상속을 염두에 둔 부모 클래스의 소멸자는 반드시
virtual로 선언해야 합니다. - 동작:
virtual ~Base()로 선언하면,delete ptr;시 v-table을 통해 실제 객체 타입(자식)의 소멸자가 먼저 호출되고, 그 다음 부모의 소멸자가 자동으로 호출되어 모든 리소스가 안전하게 해제됩니다.
2.2. 실습 코드
#include <iostream>
#include <string>
#include <vector>
class Character; // 전방 선언
// 스킬의 기반이 되는 추상 클래스 (3장에서 자세히 다룸)
class Skill {
public:
virtual ~Skill() = default;
// 순수 가상 함수: 자식 클래스는 반드시 Execute를 구현해야 함
virtual void Execute(Character& caster, Character& target) = 0;
};
// Character 클래스 (가상 함수 추가)
class Character {
protected:
std::string name;
int hp;
int power;
public:
Character(const std::string& name, int hp, int power)
: name(name), hp(hp), power(power) {}
virtual ~Character() {
std::cout << name << "의 소멸자 호출" << std::endl;
}
virtual void Attack(Character& target) {
std::cout << name << "의 기본 공격!" << std::endl;
target.TakeDamage(power);
}
void UseSkill(Skill& skill, Character& target) {
skill.Execute(*this, target);
}
void TakeDamage(int damage) {
hp -= damage;
if (hp < 0) hp = 0;
std::cout << name << "의 HP: " << hp << std::endl;
}
std::string GetName() const { return name; }
};
// Player 클래스 (Attack 함수 재정의)
class Player : public Character {
public:
Player(const std::string& name, int hp, int power)
: Character(name, hp, power) {}
// override 키워드: 부모의 가상 함수를 재정의함을 명시. 실수를 방지.
void Attack(Character& target) override {
std::cout << "영웅의 일격! " << name << "의 강력한 공격!" << std::endl;
target.TakeDamage(power * 1.5); // 플레이어는 1.5배 강한 공격
}
};
// Fireball 스킬 구현
class Fireball : public Skill {
public:
void Execute(Character& caster, Character& target) override {
std::cout << caster.GetName() << "이(가) 파이어볼 시전!" << std::endl;
target.TakeDamage(30);
}
};
int main() {
// 부모 포인터로 자식 객체를 다룸 (다형성)
Character* player = new Player("용사", 100, 15);
Character* monster = new Character("오크", 50, 10);
// player는 Player 타입이므로, v-table을 통해 Player의 Attack이 호출됨
player->Attack(*monster);
// monster는 Character 타입이므로, Character의 Attack이 호출됨
monster->Attack(*player);
std::cout << "--- 스킬 사용 ---" << std::endl;
Fireball fireball;
player->UseSkill(fireball, *monster);
// player와 monster가 Character* 타입이므로, 가상 소멸자가 필수
delete player;
delete monster;
return 0;
}
2.3. 요약
동적 다형성은 부모 클래스의 포인터나 참조로 자식 객체를 가리킬 때, 실제 객체의 타입에 따라 재정의된 함수가 호출되는 기능입니다. C++에서는 가상 함수로 이를 구현합니다.
내부적으로, 컴파일러는 virtual 함수가 하나라도 있는 클래스에 대해 가상 함수들의 주소를 담은 배열인 v-table을 생성합니다. 그리고 해당 클래스의 객체가 생성될 때, 이 v-table을 가리키는 v-ptr이라는 숨겨진 포인터를 객체 내에 포함시킵니다. 런타임에 가상 함수가 호출되면, 객체의 v-ptr을 통해 v-table에 접근하고, 거기서 실제 호출할 함수의 주소를 찾아 실행하는 방식으로 동작합니다. 이 때문에 약간의 런타임 오버헤드는 있지만 코드의 유연성과 확장성을 크게 향상시킬 수 있습니다.
3. 추상 클래스와 순수 가상 함수
3.1. 이론
3.1.1. 순수 가상 함수 (Pure Virtual Function)
- 개념: 몸체(구현)가 없이 선언만 존재하는 가상 함수입니다.
= 0;을 함수 선언 뒤에 붙여서 만듭니다. - 목적: 자식 클래스에게 "이 함수는 반드시 너희가 구체적인 내용으로 재정의해야 한다"고 강제하는 역할을 합니다. 즉, 인터페이스의 명세를 만드는 것입니다.
- 예시:
virtual void Attack() = 0;
3.1.2. 추상 클래스 (Abstract Base Class, ABC)
- 개념: 순수 가상 함수를 하나 이상 포함하는 클래스입니다.
- 특징:
- 인스턴스화 불가: 추상 클래스 타입의 객체를 직접 생성할 수 없습니다.
Character character;와 같은 코드는 컴파일 오류가 발생합니다. - 인터페이스 역할: 추상 클래스는 '설계도'와 같습니다. "모든 캐릭터는 공격(Attack) 기능이 있어야 한다"는 규칙을 정의하지만, 그 공격을 어떻게 할지는 구체적인 자식 클래스(Player, Monster)가 결정하도록 위임합니다.
- 포인터나 참조로는 사용 가능:
Character* ptr = new Player();와 같이 포인터 변수를 선언하여 자식 객체를 가리키는 용도로는 사용할 수 있습니다.
- 인스턴스화 불가: 추상 클래스 타입의 객체를 직접 생성할 수 없습니다.
3.1.3. _purecall 런타임 오류
- 발생 원인: 순수 가상 함수가 어떻게든 호출되었을 때 발생하는 런타임 오류입니다. v-table에서 순수 가상 함수에 해당하는 슬롯은 보통 0이나 에러 처리 함수의 주소로 채워져 있기 때문입니다.
- 주요 시나리오:
- 추상 클래스의 생성자나 소멸자에서 순수 가상 함수를 호출하는 경우: 객체 생성 시, 부모 생성자가 먼저 호출됩니다. 이 시점에는 아직 자식 클래스 부분이 생성되지 않았으므로 v-table도 부모의 것을 가리킵니다. 이때 순수 가상 함수를 호출하면 구현이 없으므로 오류가 발생합니다. 소멸 시에는 반대로 자식 소멸자가 먼저 호출되어 자식 부분이 사라진 상태에서 부모 소멸자가 호출되므로 같은 문제가 발생합니다.
- 구현되지 않은 자식 클래스: 드물지만, 컴파일러의 특정 상황이나 잘못된 캐스팅 등으로 순수 가상 함수를 구현하지 않은 클래스의 객체가 생성되고 해당 함수가 호출될 때 발생할 수 있습니다.
3.2. 실습 코드
#include <iostream>
#include <string>
#include <vector>
// IUsable: '사용 가능한' 모든 것들에 대한 인터페이스
// 순수 가상 함수 Use()를 포함하므로 추상 클래스입니다.
class IUsable {
public:
virtual ~IUsable() = default;
virtual void Use() = 0;
};
// Potion 클래스: IUsable 인터페이스를 구현
class Potion : public IUsable {
private:
std::string name;
public:
Potion(const std::string& name) : name(name) {}
// IUsable의 순수 가상 함수를 재정의
void Use() override {
std::cout << name << "을(를) 마셔서 체력을 회복합니다." << std::endl;
}
};
// Bomb 클래스: IUsable 인터페이스를 구현
class Bomb : public IUsable {
public:
void Use() override {
std::cout << "폭탄을 던져 주변에 피해를 줍니다!" << std::endl;
}
};
// Player 클래스: IUsable 아이템을 사용할 수 있음
class Player {
private:
std::string name;
std::vector<IUsable*> inventory;
public:
Player(const std::string& name) : name(name) {}
~Player() {
for (IUsable* item : inventory) {
delete item;
}
inventory.clear();
}
void AddItem(IUsable* item) {
inventory.push_back(item);
std::cout << name << "이(가) 아이템을 주웠습니다." << std::endl;
}
void UseFirstItem() {
if (!inventory.empty()) {
// item의 실제 타입이 Potion인지 Bomb인지 신경 쓸 필요가 없음 (다형성)
// IUsable 인터페이스에 정의된 Use()를 호출할 뿐
inventory[0]->Use();
delete inventory[0];
inventory.erase(inventory.begin());
} else {
std::cout << "가진 아이템이 없습니다." << std::endl;
}
}
};
int main() {
// IUsable usable; // 컴파일 에러! 추상 클래스는 인스턴스화할 수 없음
Player player("모험가");
player.AddItem(new Potion("힐링 포션"));
player.AddItem(new Bomb());
player.UseFirstItem(); // Potion의 Use()가 호출됨
player.UseFirstItem(); // Bomb의 Use()가 호출됨
player.UseFirstItem();
return 0;
}
3.3. 요약
순수 가상 함수는 = 0;으로 선언되어 구현부가 없는 가상 함수이며, 이를 하나 이상 포함하는 클래스를 추상 클래스라고 합니다.
추상 클래스는 인스턴스화할 수 없으며, 주로 자식 클래스가 반드시 구현해야 할 함수의 인터페이스를 명세하는 용도로 사용됩니다. 예를 들어, 모든 무기가 Attack() 기능을 가져야 하지만 무기마다 공격 방식이 다를 때, Weapon이라는 추상 클래스에 순수 가상 함수 Attack()을 선언하여 모든 구체적인 무기 클래스들이 이를 재정의하도록 강제할 수 있습니다.
_purecall 에러는 구현되지 않은 순수 가상 함수가 호출될 때 발생하는 런타임 에러로, 가장 흔한 경우는 추상 클래스의 생성자나 소멸자 내부에서 순수 가상 함수를 호출했을 때 발생합니다.
'C++' 카테고리의 다른 글
| 전위 증가(++it)와 후위 증가(it++)의 차이 (0) | 2025.11.27 |
|---|---|
| 템플릿(Template)과 매크로(Macro) (0) | 2025.11.21 |
| RTTI와 RAII (0) | 2025.11.20 |
| 인라인 함수 Inline Function (0) | 2025.11.19 |
| 실수 자료형을 사용할 때 발생할 수 있는 문제 (0) | 2025.11.19 |
