C++ - OOP와 다형성의 내부 구조

yys·2026년 8월 12일

TIL

목록 보기
83/86

OOP란 무엇인가?


OOP(Object-Oriented Programming)는 프로그램을 단순한 함수들의 집합이 아니라 상태와 행동을 가지는 객체들의 관계로 구성하는 프로그래밍 방식이다.

OOP를 설명할 때 일반적으로 다음 네 가지 특성을 이야기한다.

캡슐화


캡슐화는 데이터와 그 데이터를 다루는 코드를 하나의 단위로 묶고, 외부에는 정해진 인터페이스만 노출하는 것이다.

예를 들어 다음 코드를 생각해보자.

class Player
{
public:
    int Health = 100;
};

Health가 public이라면 외부에서 자유롭게 수정할 수 있다.

Player MyPlayer;

MyPlayer.Health = -10000;
MyPlayer.Health = 9999999;

Player 입장에서는 자신의 체력이 어떤 규칙으로 변경되는지 통제할 수 없다.

이를 다음처럼 변경할 수 있다.

class Player
{
public:
    void TakeDamage(int Damage)
    {
        if (Damage <= 0)
        {
            return;
        }

        Health -= Damage;

        if (Health < 0)
        {
            Health = 0;
        }
    }

    int GetHealth() const
    {
        return Health;
    }

private:
    int Health = 100;
};

이제 외부에서 직접 체력을 변경하면 컴파일 에러가 발생한다.

Player MyPlayer;

MyPlayer.Health = -100; // 컴파일 에러

대신 클래스가 제공하는 인터페이스를 이용해야 한다.

MyPlayer.TakeDamage(30);

중요한 점은 Player가 자신의 상태를 변경하는 규칙을 스스로 관리한다는 것이다.

public, protected, private 접근 지정자는 이때 «무엇을 노출할지»를 정하는 도구이며, 이 부분을 특히 정보 은닉이라고 부른다.

추상화


추상화는 대상에서 지금 필요한 부분만 남기고 나머지 세부 사항은 감추어 다루기 쉬운 개념으로 만드는 것이다.

예를 들어 게임의 Player, Enemy, NPC를 움직이고 피해를 받는 무언가라는 관점에서만 바라보면 다음과 같은 개념 하나로 다룰 수 있다.

class Character
{
public:
    void Move();
    void TakeDamage(int Damage);

protected:
    int Health = 100;
};

Character를 사용하는 쪽은 이동과 피격만 알면 되고, 각 캐릭터가 내부에서 어떻게 이동 처리를 하는지는 몰라도 된다.

상속


상속은 기존 클래스가 가지고 있는 특성을 새로운 클래스가 물려받아 확장할 수 있게 만드는 기능이다.

class Character
{
public:
    void Move();

protected:
    int Health = 100;
};

class Player : public Character
{
public:
    void UseItem();
};

class Enemy : public Character
{
public:
    void AttackPlayer();
};

다형성


다형성은 같은 인터페이스를 사용하면서 실제 객체에 따라 서로 다른 동작을 수행할 수 있게 만드는 특성이다.

예를 들어 모든 무기에 Fire라는 동일한 인터페이스를 제공할 수 있다.

class Weapon
{
public:
    virtual ~Weapon() = default;

    virtual void Fire()
    {
        std::cout << "Weapon Fire\n";
    }
};

class Rifle : public Weapon
{
public:
    virtual void Fire() override
    {
        std::cout << "Rifle Fire\n";
    }
};

class Shotgun : public Weapon
{
public:
    virtual void Fire() override
    {
        std::cout << "Shotgun Fire\n";
    }
};

다음 함수는 어떤 구체적인 Weapon인지 알 필요가 없다.

void ExecuteFire(Weapon& WeaponObject)
{
    WeaponObject.Fire();
}
Rifle MyRifle;
Shotgun MyShotgun;

ExecuteFire(MyRifle); // Rifle Fire
ExecuteFire(MyShotgun); // Shotgun Fire

호출 코드는 WeaponObject.Fire(); 하나지만 실제 객체의 타입에 따라 다른 함수가 실행된다.

C++ 다형성의 두 종류


C++의 다형성은 크게 다음 정적 다형성과 동적 다형성, 두 종류로 구분한다.

정적 다형성


정적 다형성은 어떤 함수를 실행할지가 컴파일 시점에 결정된다.

void Print(int Value)
{
    std::cout << "int\n";
}

void Print(float Value)
{
    std::cout << "float\n";
}

Print(10);      // Print(int)
Print(10.0f);   // Print(float)

컴파일러가 인자의 타입을 보고 호출 대상을 확정한다.

호출 대상이 컴파일 시점에 확정되므로 간접 호출이 없고 인라인 최적화가 가능하다.

동적 다형성


동적 다형성은 실행 중 실제 객체의 타입에 따라 호출할 함수가 결정되는 방식이다.

class Character
{
public:
    virtual ~Character() = default;

    virtual void Attack()
    {
        std::cout << "Character Attack\n";
    }
};

class Player : public Character
{
public:
    virtual void Attack() override
    {
        std::cout << "Player Attack\n";
    }
};
Player MyPlayer;

Character* CharacterPtr = &MyPlayer;

CharacterPtr->Attack(); // Player Attack

포인터의 정적 타입은 Character*이지만 동적 타입은 Player이므로 Player::Attack이 실행된다.

가상 함수와 override


동적 바인딩에서 언급이 된 가상 함수는 Base Class의 포인터나 참조로 호출하더라도 실제 객체 타입에 맞는 Override 함수가 호출되도록 만드는 함수이며 virtual 키워드를 사용한다.

virtual이 없으면 어떻게 되는지 비교해보자.

class Character
{
public:
    void Attack() { std::cout << "Character Attack\n"; }
};

class Player : public Character
{
public:
    void Attack() { std::cout << "Player Attack\n"; }
};
Player MyPlayer;
Character* Ptr = &MyPlayer;

Ptr->Attack(); // Character Attack

이 경우 Player::Attack은 Override가 아니라 정적 타입인 Character*를 기준으로 결정된다.

다만 virtual 키워드를 붙일 경우 현재 타입 기준으로 보지 않고 실제 메모리에 저장된 타입을 기준으로 결정된다.

가상 소멸자


다형성을 목적으로 사용하는 Base Class에서는 소멸자를 반드시 확인해야 한다.

class Character
{
public:
    virtual void Attack() {}

    ~Character() { std::cout << "Character Destructor\n"; } // virtual 아님
};

class Player : public Character
{
public:
    ~Player() { std::cout << "Player Destructor\n"; }
};
Character* Ptr = new Player();

delete Ptr; // Player 소멸자 호출 x

여기서 정적 타입과 동적 타입이 다른 객체를 Pointer로 delete할 때 Base 소멸자가 virtual이 아니면 Derived 소멸자가 호출되지 않는다.
그래서 위 코드에서는 Player::~Player가 실행되지 않아 Player가 잡고 있던 자원이 그대로 누수되는 형태로 나타나는 경우가 많다.

따라서 Base Class에서는 소멸자를 virtual로 만들어야 한다.

class Character
{
public:
    virtual ~Character() = default;

    virtual void Attack() {}
};

순수 가상 함수와 추상 클래스


순수 가상 함수는 Base에서 구현을 제공하지 않고 Derived가 해당 인터페이스를 구현하도록 강제할 때 사용하며 = 0으로 선언한다.

class Weapon
{
public:
    virtual ~Weapon() = default;

    virtual void Fire() = 0;
};

순수 가상 함수의 최종 Override의 내용 구현이 정의되지 않은 클래스는 추상 클래스가 되어 객체를 만들 수 없다.

Weapon MyWeapon; // 추상 클래스로 인한 컴파일 에러

그래서 상속한 클래스가 이를 구현하면 객체를 만들 수 있다.

class Rifle : public Weapon
{
public:
    virtual void Fire() override { std::cout << "Rifle Fire\n"; }
};

Rifle MyRifle;

Weapon* WeaponPtr = &MyRifle;
WeaponPtr->Fire(); // Rifle Fire

순수 가상함수를 사용하는 이유는 특정 함수의 기능을 자식이 정의해야 함을 강제하고 싶을 때이다.

그래서 다음 처럼 자식에서 함수를 선언하지 않으면 컴파일 에러가 나게 된다.

class Rifle : public Weapon
{
public:
    void Fire() {}
};

Rifle MyRifle; // 컴파일 에러

순수 가상 함수의 구현

여담으로, = 0으로 선언한 함수에도 클래스 밖에서 정의를 줄 수 있다.

class Character
{
public:
    virtual ~Character() = default;
    virtual void Attack() = 0;
};

void Character::Attack()
{
    std::cout << "Character Default Attack\n";
}

다만 정의가 있어도 Character는 여전히 추상 클래스인 것은 동일하지만, Derived에서 명시적으로 호출할 수 있다.

class Player : public Character
{
public:
    void Attack() override
    {
        Character::Attack();          
        std::cout << "Player Attack\n";
        
        /*
        *Character Default Attack
        *Player Attack
        */
    }
};

즉, 순수 가상함수는 해당 클래스에서 최종 구현으로 인정하지 않는 virtual 함수라고 보면 된다.

profile
게임 개발 지망생

0개의 댓글