객체지향프로그래밍(18) - 가상함수와 업 캐스팅, 다운 캐스팅

업 캐스팅과 다운 캐스팅을 통한 가상함수의 이해

Featured image

🔚 짧게 하는 복습

✅ 1. 오버라이딩의 정의를 안다.

✅ 2. 오버로딩과 오버라이딩의 차이를 안다.

✅ 3. 오버라이딩의 장단점을 안다.

혹시 기억이 안 난다면, 다시 돌아가자


저번 시간에는 오버라이딩이라는 기술로 하나의 함수가 클래스 내에서 여러 번 재정의할 수 있음을 깨달았다.

이번 시간에는 하나의 코드가 여러 가지로 해석될 수 있다는 다형성과 그를 가능하게 해주는 동적 바인딩, 가상 함수를 알아보겠다.


또다시 돌아온 포켓몬

다시 포켓몬 게임을 만드는 것으로 돌아가 보자.

실제 포켓몬 게임에서는 몇몇 속성이 특수 효과를 가진다.

불 속성은 공격하고 상대를 화상 상태에 빠트려 지속 데미지를 준다거나, 전기 속성은 상대를 마비 상태에 빠트려 한 턴을 쉬게 하는 등 몇몇 속성을 가진다.

우리는 이를 오버라이딩을 통해서 가볍게 의사 코드(psuedo code)정도로만 나타내보겠다.


#include <iostream>

enum { Fire, Water, Grass };

class pokemon {
public:
    pokemon() {}
    void attack() { std::cout << "공격합니다~" << std::endl; }
};

class bukein : public pokemon {
    int type;

public:
    bukein() : pokemon(), type(Fire) {}
    void attack() {
        std::cout << "불 공격합니다~" << std::endl;
        std::cout << "확률에 따라 화상으로 추가 데미지를 줍니다." << std::endl;
    }
}; // 불 속성 브케인은 확률에 따라 추가 데미지를 주게하고 싶다.

class riako : public pokemon {
    int type;

public:
    riako() : pokemon(), type(Water) {}
    void attack() {
        std::cout << "물 공격합니다~" << std::endl;
        std::cout << "확률에 따라 자신의 hp를 회복합니다." << std::endl;
    }
}; // 물 속성 리아코는 확률에 따라 자신의 hp를 회복시키고 싶다.

class chicorita : public pokemon {
    int type;

public:
    chicorita() : pokemon(), type(Grass) {}
    void attack() {
        std::cout << "풀 공격합니다~" << std::endl;
        std::cout << "확률에 따라 보호막으로 다음 공격을 무시합니다." << std::endl;
    }
}; // 풀 속성 치코리타는 확률에 따라 보호막을 만들어 다음 공격을 무시하고 싶다.

int main() {
    chicorita pomon1;
    bukein pomon2;
    riako pomon3;

    pomon1.attack();
    pomon2.attack();
    pomon3.attack();
}

결과

 공격합니다~
확률에 따라 보호막으로 다음 공격을 무시합니다.
 공격합니다~
확률에 따라 화상으로 추가 데미지를 줍니다.
 공격합니다~
확률에 따라 자신의 hp를 회복합니다.

역시 예상한 대로 잘 되는 모습이다.

그런데 우리의 욕심은 끝이 없고, 어차피 7저 attack이라는거 함수명도 다 똑같은데 반복문으로 줄이고 싶다.


업 캐스팅이란?

예전에 C강의에서 읽어볼 거리를 통해 스치듯이 다루었지만, void 포인터를 이용해서 배열 내 형변환을 할 수 있었다.

예를 들어

#include <stdio.h>
#include <stdlib.h>//아직은 몰라도 된다.

int main() {
    int num = 123;
    double dbl = 456.789;
    char ch = 'A';

    void* arr[3]; //void형 포인터를 이용해서 포인터 배열을 만든다.

    arr[0] = &num;
    arr[1] = &dbl;
    arr[2] = &ch;
    //다양한 자료형을 넣는 배열을 만들어 낼 수 있다.

    // void 포인터로 저장된 주소를 사용하여 각 데이터 형식에 접근
    printf("Integer: %d\n", *(int*)arr[0]);
    printf("Double: %lf\n", *(double*)arr[1]);
    printf("Char: %c\n", *(char*)arr[2]);

    return 0;
}

이런게 있었는데, 포인터를 이용하면 우리가 원하는 것을 해결할 수 있을지 모른다.

우선 첫 번째 궁금증은, 마치 void 포인터를 이용한 것처럼 pokemon 포인터를 이용하여 브케인이든, 리아코든, 치코리타의 주솟값을 대입할 수 있을까?

결과를 먼저 말하면 가능하다.

이는 상위 클래스에 멤버 변수는 하위 클래스에서 모두 초기화되기 때문인데, 쉽게 말해서 하위 클래스의 할당 된 메모리 중 상위 클래스를 만드는데 필요한 부분만큼만 사용하면 되기 때문이다. (지금 이해 안 돼도 괜찮다. 아래에서 다시 설명하기 때문이다)

그래서 아래와 같은 코드가 가능하다.

int main(){
    pokemon* pomon;
    chicorita pomon1;
    bukein pomon2;
    riako pomon3;

    pomon = &pomon1;
    pomon = &pomon2;
    pomon = &pomon3;
}

이는 하위 클래스에서 상위 클래스로 형변환(casting)한다고 하고, 밑에서 위로 형변환이기 때문에 업 캐스팅(up casting)이라고 한다.

이제 pokemon 포인터로 하위 클래스 포켓몬들을 모두 가리킬 수 있는지 확인했으니, 이런 코드가 가능한지 확인하면 된다.

int main() {
    pokemon* pomons[3]; // 포인터를 저장하는 배열이어야하니!
    chicorita pomon1;
    bukein pomon2;
    riako pomon3;

    pomons[0] = &pomon1;
    pomons[1] = &pomon2;
    pomons[2] = &pomon3;

    for (int i = 0; i < 3; i++) {
        pomons[i]->attack();
    }
}

하지만 결과는 이렇게 나온다.

공격합니다~
공격합니다~
공격합니다~

이 이유는 메소드 호출 원리를 잘 생각해보면 된다.

pomons 배열에 넣는 순간, 모든 포켓몬은 pokemon* 객체가 된다.

메소드 호출은 어떻게 된다고? 자신의 클래스부터 아래에서 위로

당연히 pokemon 클래스 아래에서 오버라이딩 된 자신들의 함수가 호출되지 않는다.

이렇기에 이 문제를 해결하려면, 몇 가지 테크닉이 필요하다.

가장 쉬운 방법은 다시 하위 클래스로 형변환 해주면 된다.

다운 캐스팅이란?

다운 캐스팅이란 업 캐스팅의 반대로 상위 클래스를 하위 클래스로 다시 바꿔주는 것이다.

아래 예제를 다시 보자.

int main() {
    pokemon* pomons[3]; // 포인터를 저장하는 배열이어야하니!
    chicorita pomon1;
    bukein pomon2;
    riako pomon3;

    pomons[0] = &pomon1;
    pomons[1] = &pomon2;
    pomons[2] = &pomon3;

    ((chicorita*)pomons[0])->attack();
    ((bukein*)pomons[1])->attack();
    ((riako*)pomons[2])->attack();
}

결과

 공격합니다~
확률에 따라 보호막으로 다음 공격을 무시합니다.
 공격합니다~
확률에 따라 화상으로 추가 데미지를 줍니다.
 공격합니다~
확률에 따라 자신의 hp를 회복합니다.

다행히 잘 되는 모습이다. 그런데 이거 우리가 원하던 반복문과는 거리가 멀다.

또한, 우리가 원소 하나하나 캐스팅을 직접 해줘야하니 여간 번거로운 것이 아니다.

이렇게 프로그램이 실행 전에(이를 런타임 전이라고 한다.) 컴파일 시간에 객체를 결정시켜주는 것은 정적 바인딩(static binding) 혹은 이른 바인딩(early binding)이라고 한다.

이런 식으로 다운 캐스팅을 활용한 해결은 좋지 않다.

그 이유는 다운 캐스팅은 오류 발생의 확률이 높기 때문이다. 왜 그런지 간단하게 알아보자.


다운 캐스팅의 문제

아래의 코드와 메모리 구조를 통해서 업 캐스팅은 자유롭지만, 다운 캐스팅은 제한적인 이유를 알아보자.

class animal{
    private:
    int age;
    char* name;
}
class dog : public animal{
    private:
    char* breed;
}

여기서 만약 dog 객체를 만들었다고 해보자.

처음에 만들어지는 메모리 구조는 이럴 것이다.

그런데 여기서 animal로 업 캐스팅하면 아래 그림처럼 age, name만큼만 사용하면 된다.

그리고 다시 dog로 다운 캐스팅해도 프로그램의 진행상에 큰 문제는 없다.

반면에 animal 객체를 다운 캐스팅한다고 해보자.

처음 객체의 메모리 구조는 이렇다.

그런데 여기서 다운 캐스팅을 한다? 그러면 그림처럼 breed에는 쓰레깃값이 들어갈 수도 있고, 메모리상의 위협이 될 수도 있다.

그렇기에 원래 하위 클래스 객체를 업 캐스팅했다가 다시 돌릴 때만, 다운 캐스팅을 보통 이용하는 것이다.

그렇다면 우리가 진짜 원하는 반복문 구조를 만들 수는 없을까?


가상 함수를 통한 동적 바인딩

딱 하나의 키워드만 상위 클래스에 넣으면 해결된다.

바로 virtual이라는 키워드이다.

이를 오버라이딩 될 메소드 앞에 붙이면 그 메소드가 실행되기 전에 객체가 무슨 타입인지 먼저 확인한다.

그리고 난 후 타입이 결정되면 그에 맞는 메소드를 실행시킨다.

즉 이 방법은 컴파일 시간이 아니라 런타임에 객체가 무엇인지 결정되는 것이다.

이런 바인딩을 동적 바인딩(dynamic binding) 혹은 늦은 바인딩(late binding)이라고 한다.

아래의 예를 직접 보자.


class pokemon {
public:
    pokemon() {}
    virtual void attack() { std::cout << "공격합니다~" << std::endl; } // 가상 함수
};

// 중략 ...

int main() {
    pokemon* pomons[3]; // 포인터를 저장하는 배열이어야하니!
    chicorita pomon1;
    bukein pomon2;
    riako pomon3;

    pomons[0] = &pomon1;
    pomons[1] = &pomon2;
    pomons[2] = &pomon3;

    for (int i = 0; i < 3; i++) {
        pomons[i]->attack();
    }
}

바뀐 것은 virtual 키워드가 상위 클래스에 들어갔다는 점뿐이다. 하지만 결과를 보면?

 공격합니다~
확률에 따라 보호막으로 다음 공격을 무시합니다.
 공격합니다~
확률에 따라 화상으로 추가 데미지를 줍니다.
 공격합니다~
확률에 따라 자신의 hp를 회복합니다.

원하는 대로 실행되는 모습을 알 수 있다.

이처럼 virtual 키워드로 작성된 함수를 가상 함수라고 한다.

그리고 pomons[i]->attack(); 구문처럼 하나의 코드가 여러 가지 역할을 수행할 수 있는 것다형성이라고 한다.

가상 함수와 다형성을 이용하면 코드의 유연성을 높이고 재사용성을 높여준다.

그리고 하나의 코드로 읽히기 때문에, 가독성과 유지보수 차원에서도 도움이 된다.


정적 바인딩과 동적 바인딩의 차이

그럼 모든 함수를 virtual로 하고 동적 바인딩으로 넘기면 되지 않겠냐고 물을 수 있다.

하지만 동적 바인딩은 런타임 시간에 객체가 무엇인지 확인하는 시간이 필요하다.

그렇기에 정적 바인딩보다 실행이 늦어질 수밖에 없다.

또한, 동적 바인딩의 오류는 런타임 시간에 발생하기 때문에, 컴파일 에러와 비교하면 훨씬 찾기 힘들다.

그렇기에 장단점을 잘 비교해서 코드에 적합하게 사용해야 한다.


📖 오늘의 핵심(다 알기 전까지는 넘어가지 말자❗)

✅ 1. 업 캐스팅과 다운 캐스팅의 정의와 원리를 안다.

✅ 2. 다운 캐스팅의 조심해야 할 점을 안다.

✅ 3. 정적 바인딩과 동적 바인딩의 차이와 장단점을 안다.

✅ 4. 가상 함수의 정의와 원리를 안다.

✅ 5. 다형성의 정의를 안다.

⚠️ 다운 캐스팅은 오직 하위 클래스에서 업 캐스팅을 하고 돌려놓을 때만 사용하자.

⚠️ 오버로딩도 다형성의 일환이다.

💣 과제, 강의의 의사 코드를 직접 구현해서 포켓몬 게임을 만들어보자!(난이도 中)

🔜 더 공부해보기,

읽어볼 거리(1) - dynamic_cast란?

읽어볼 거리(2) - 가상 함수의 원리