C++ Basic

[C++ Basic] Casting

서론

이번시간에는 Casting이라는 개념에 대해서 학습해 보도록 하겠다. 하지만 그 전에 Composition과 Aggregation의 차이에 대해서 먼저 알아보자.

1. Composition

상속이 아니라 멤버 변수에 포함을 시켜서 (Part-on) 클래스를 종속시키는 방법이다.
생명주기를 포함하기 때문에 두 클래스의 생명주기가 일치한다는 특징이 있다.

class Character {
public:
    int hp = 100;
    int power = 10;

    void TakeDamage(int dmg) { hp -= dmg; }
};

class MonsterA {
public:
    void AttackPlayer(Character& target) {
        mainRole.Attack(target);
    }

private:
    Character mainBody; // 컴포지션: MonsterA가 직접 소유
    Monster mainRole;   // 컴포지션
};

이렇게 보면 MonsterA가 Character이랑 Monster를 상속받지 않고 가지고 있는걸 확인할 수 있다.

MonstaerA객체가 사라지게 되면 자연스럽게 Character이랑 Monster도 사라지기 때문에 컴포지션이다.

조금 정리해보자면, 어떤 클래스가 다른 객체에 대한 메모리 크기 만큼을 별도로 할당받아서 거기에서 직접 관리하고 있으면 컴포지션이라고 생각하면 된다. 따라서 컴포지션 클래스가 삭제가 되면 가지고 있던 다른 클래스에 대한 내용도 지워지는 것이다.

2. 어그리게이션(Aggregation)

상속이 아니라 멤버 변수로 가지고 있는다. (has-a)
생명주기는 같이 포함하지 않고 그냥 참조하고 있는 상태를 의미한다.

class MonsterB {
public:
    MonsterB(Character& character, Monster& monster)
        : mainBody(character), mainRole(monster) {}

    void AttackPlayer(Character& target) {
        mainRole.Attack(target);
    }

private:
    Character& mainBody; // 어그리게이션: 외부 객체를 참조만
    Monster&   mainRole;
};

어그리게이션은 가지고 있는것 보다는 참조하고 있는 개념에 가깝다. 따라서 MonsterB가 지워지더라도 참조하고 있던애들이 지워지지는 않는다.

  • 참고로 (매개변수) : mainBody(character), mainRole(monster) 이렇게 표현하는 건 생성자에서 상속을 의미하는게 아니라 값을 초기화 할때 사용하는 것임.

컴포지션과는 다르게 포인터 변수만큼의 크기를 가지고 이미 존재하는 다른 클래스를 참조하는 형식이다. 따라서 어그리게이션 클래스는 지워지더라도 참조하고 있던 다른 클래스들은 영향을 받지 않는 것이 어그리게이션이다.

정리해보자면 Composition과 Aggregation은 둘 다 클래스가 다른 클래스를 가지고 있는 “Has-a” 관계이다. 하지만 생명주기에 있어서 부모에 의존적인지 차이가 있고, 또한 Composition은 1:1로 가지고 있지만 Aggregation은 참조 형태기 때문에 공유 소유가 가능하다는 점이 있다.

업캐스팅(Up Casting)

자식을 부모 타입으로 바꾸는 것을 업캐스팅이라고 한다.

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

class Warrior : public Player {
public:
    void Attack() override { std::cout << "Warrior Slash\n"; }
};

int main() {
    Warrior w;         // Child
    Player* p = &w;    // Up Casting (Warrior* → Player*)

    p->Attack();       // Warrior Slash (virtual 덕분에 자식 함수 실행)
}

이렇게 하는 것이 업캐스팅이다.

이때 w는 [player부분] + [warrior의 부가 기능]만큼의 메모리 공간을 할당받게 되는데. p같은 경우는 포인터기 때문에 8바이트의 포인터 공간을 가지게 되고 w의 [player부분]을 가르키게 되는것이다.

그렇다면 w의 [player부분] + [warrior의 부가 기능] 부분은 연속적인 메모리 공간에 할당이 될텐데 이왕 p가 w의 player부분을 가르키고 있으니까 전체의 기능을 다 사용할 수 있게 하면 더 이득 아닐까??

-> 타입안정성 때문에 player으로 선언된 p는 w의 player부분만 읽게 된다.

여기서 알아야 할 점은. 우리가 Player이라고 업캐스팅을 했어도. 오버라이딩된 메서드가 실행이 된다는 것이다. 따라서 p->Attack()을 하면 자식의 Warrior Slash가 동작한다.

-> 컴파일러는 Player에 virtual Attack()이 있다는 사실만 알고있다가 p->Attack()을 호출하게 되면 실제 객체 타입을 보고 결정하게 된다. 따라서 자식이 오버라이딩을 했으면 그걸 실행하게 되고 자식이 오버라이딩을 안했으면 부모를 실행하게 되는것이다. 이를 동적 바인딩이라고 한다. (참고로 virtual 키워드가 있어야 재정의 가능)

만일 이게 헷갈린다면 쉽게 다형성을 사용하는 virtual – override 상황에서 동적 바인딩이 일어난다고 생각해도 문제는 없을 것이다.

다운캐스팅(Down Casting)

업캐스팅과는 반대로 부모->자식으로 바꾸는 것을 다운캐스팅이라고 한다.

int main() {
    Warrior w;
    Player* p = &w;        // Up Casting (OK)

    Warrior* w2 = (Warrior*)p;        // C-Style Down casting (위험)
    Warrior* w3 = dynamic_cast<Warrior*>(p); // 안전한 방식 (RTTI 필요)
}

다운캐스팅은 player->warrior로 변환하는것을 의미하는데, 대충봐도 더 확실한 상황에서 사용해야 한다. 왜냐하면 실제로 warrior이 아닌걸 바꾸면 오류가 나기 때문이다.

따라서 C++에서는 dynamic_cast<T>를 통해 변환해준다.

가상 함수 테이블 (Vtable)

자식이 재정의할 수 있고, 런타임에 객체의 실제 타입을 기준으로 어떤 함수 실행할지 결정하게 되는데 이때 실제 타입 판단을 위해 클래스마다 만들어지는 함수 포인터 배열을 의미한다.

  • vtable은 virtual 함수가 있는 클래스마다 자동으로 생성된다.
  • 포인터 하나(vfptr: 보통 8바이트)만 추가로 메모리를 차지하게 된다.
  • 만약에 자식이 override를 하지 않았으면 부모의 함수 포인터가 들어가게 된다. 즉 호출 시 어떤 함수가 실행이 될지 그 참조를 배열로 저장해둔 형태를 의미한다.
  • 참고로 부모가 virtual이면 자식의 그 메서드도 virtual이 된다. 따라서 vtable에 추가가 된다.
  • virtual이 여러개면 여러 함수가 배열 형태로 존재하게 된다.
  • 업캐스팅된 메서드는 vtable을 확인해 어떤 메서드를 실행시킬지 결정하게 된다.

순수 가상 함수

부모 클래스에서 가상 함수를 정의 없이 선언만 진행하는 것을 의미한다.
다르게 말하자면 자식 클래스에서 반드시 overriding 해야하는 것을 의미한다.

// = 0을 하면 순수가상함수로 선언됨
virtual void attack_special(player target_player) = 0;

C++에서는 단순하게 =0을 통해서 순수가상 함수를 선언할 수 있다.

public abstract class Character
{
    // 순수 가상 함수에 해당 (구현 없음)
    public abstract void Attack();
}

C#에서는 abstract를 붙여서 pure virtual function임을 알린다.

그렇다면 이런 순수 가상 함수는 왜 사용하는 것일까?

class Monster {
public:
    virtual void Attack() = 0;   // 순수 가상 함수
};

std::vector<Monster*> monsters;
monsters.push_back(new Goblin);
monsters.push_back(new Dragon);
monsters.push_back(new Boss);

for (Monster* m : monsters) {
    m->Attack();  // 어떤 자식인지 몰라도 호출 가능
}

바로 이런식으로 사용하기 위함이다. 순수가상함수로 자식들이 메서드를 오버라이딩하게 강제하게 되면 이렇게 업캐스팅된 배열에 대해서 반복문을 돌렸을 때 안전하게 처리할 수 있다는 점이다.

이는 나중에 코드가 복잡해져서 실수로 꼭 재정의 해야하는 부분을 하지 않았을 상황을 대비하는 것이라고 볼 수 있다. 심지어 순수 가상 함수가 아니면 자식에 메서드가 없으면 어차피 부모의 메서드를 사용하기 때문에 런타임중에 오류를 쉽게 찾을 수 없다. 실제로 게임을 돌려보면서 찾아야 하기 때문에 비용이 더 많이 들게 된다.

가상 소멸자

class Base {
public:
    Base()  { std::cout << "Base 생성\n"; }
    ~Base() { std::cout << "Base 소멸\n"; }   // ← virtual 아님
};

class Derived : public Base {
public:
    Derived()  { data = new int[100]; std::cout << "Derived 생성\n"; }
    ~Derived() { 
        delete[] data; 
        std::cout << "Derived 소멸\n"; 
    }
private:
    int* data;
};

int main() {
    Base* p = new Derived();  // 업캐스팅
    delete p;                 // 문제 지점
}

만약 가상 소멸자가 없이 그냥 소멸자가 있다면. 업캐스팅 상태에서 지우게 되면 Base의 소멸자만 실행이 되고 Derived의 소멸자는 실행되지 않게 된다. 따라서 Derived 안에서 new를 통해 만들어진 data는 어디선가 누수가 발생하게 되는것이다.

class Base {
public:
    Base()  { std::cout << "Base 생성\n"; }
    virtual ~Base() { std::cout << "Base 소멸\n"; }  // ← virtual 추가
};

class Derived : public Base {
public:
    Derived()  { data = new int[100]; std::cout << "Derived 생성\n"; }
    ~Derived() override { 
        delete[] data; 
        std::cout << "Derived 소멸\n"; 
    }
private:
    int* data;
};

int main() {
    Base* p = new Derived();  // 업캐스팅
    delete p;                 // 이제 OK
}

이는 이렇게 소멸자에 virtual만 붙이면 해결이 된다. delete p가 호출이 되면 p가 실제로 가르키고 있는 객체 타입 (Derived)의 vtable을 참조하게 된다. 그래서 Derived의 소멸자가 실행이 되고 그 다음에 Base의 소멸자가 실행이 된다. 이거는 소멸자만 해당이 되는거고 C++이 객체 파괴 안정성을 보장하기 위해 소멸자 체인을 해주는 것이다.
일반 메서드는 위에서 했던 거 처럼 자식이 실행되면 부모가 실행되지 않는다.

참고로 생성자, 소멸자는 특이하게 부모와 자식이 둘 다 실행이 되는데 순서는

부모 생성자
자식 생성자
자식 소멸자
부모 소멸자

이런 순서로 진행이 된다.

만약 일반 메서드에서 부모 메서드도 같이 호출하고 싶다면

class Child : public Parent {
public:
    void show() override {
        Parent::show(); // 1. 부모의 show()를 먼저 실행!
        std::cout << "자식의 추가 기능\n"; // 2. 자식만의 기능 실행
    }
};

이런식으로 Parent를 호출하여 동작시키는 방법이 있다.

추상 클래스

순수가상함수가 하나라도 포함되어있으면 추상클래스임.

class Character {
public:
    virtual void Update() = 0; // 순수 가상 함수 (추상클래스 만들기)
};

그냥 이런식으로 순수가상함수가 있다면 추상클래스이다.

public abstract class Character
{
    public abstract void Update(); // 추상 메서드 (구현 없음)
}

C#에서는 뭐 똑같이 abstract가 있으면 추상 클래스가 된다.

정적 멤버

class ClassName() {
 static int num; // <- 이렇게 정적 멤버 선언
}

ClassName::num = 3; // 이런식으로 어떤 클래스의 정적 멤버인지 표시하고 동작시킨다.

참고로 c++에는 정적 클래스가 존재하지않는다. 따라서 static class형태가 존재하지 않아 각 멤버변수를 별도로 static처리를 해야한다.

+) 함수이름 (const int 변수 이름) 이렇게 만들어진거는 변수를 바꾸지 않고 상수 취급하겠다는 의미이다. readonly와 동일.

접근 지정자

C++에는 여러 접근 지정자가 존재하는데, 이를 한번 정리해보도록 하겠다.

상황지정자이유
static 멤버Class::member클래스 전체 공유 (할당 방식 무관)
동적 할당 (new)포인터->member포인터가 가르키는 비정적 멤버
스택 객체 (지역변수)객체.member객체의 비정적 멤버

<요약>
:: → “클래스 전체 공유” (static)
. → “내 것” (객체 소유)
-> → “너의 것” (포인터가 가리키는 객체)

Velog에서 보기: https://velog.io/@yoonbro/C-%EA%B8%B0%EB%B3%B82

lyh0309

View Comments

  • C++에서 헷갈리기 쉬운 개념들을 한 글에서 흐름대로 정리해주셔서 이해하기 좋았습니다!

    특히 업캐스팅 -> 다운캐스팅 -> virtual/vtable -> 순수 가상 함수 -> 가상 소멸자로 이어지는 부분이 서로 어떻게 연결되는지 파악하는 데 도움이 됐어요. 단순히 문법만 설명하는 게 아니라 게임 개발 상황을 예시로 들어주셔서 더 와닿았습니다.

    Composition과 Aggregation의 차이도 생명주기와 소유 관계를 기준으로 설명해주셔서 이해하기 쉬웠습니다. C++을 공부하면서 자주 헷갈렸던 부분인데 좋은 정리 감사합니다!

Recent Posts

[C++] Lock, Mutex, condition variable

Race Condition 서로 다른 Thread에서 같은 메모리를 공유할 때 같은 자원을 사용하면서 값이 이상하게 되는…

3 weeks ago

[C++ Basic] Move Semantics, Lvalue, Rvalue

서론 c++을 이용해서 개발을 하다보면 복사 대신에 이동연산을 통해서 여러 이점을 얻는다는 정보를 확인할 수…

4 weeks ago

[C++ Basic] Pointer에 대하여

서론 C++에서 진짜 너무 많이 등장하는 개념이 바로 Pointer이다. 메모리의 주소를 관리하고~ 뭐시기~ 항상 등장하는데,…

1 month ago

[C++ Basic] Reference, Template, Allocate

서론 c++ basic을 공부하기 위해서는 다른 언어들과의 기본적인 차이를 이해해야 한다. 그 차이는 바로 메모리…

2 months ago