개발하는 리프터 꽃게맨입니다.
[C++] 클래스 상속 본문
개요
객체 지향 프로그래밍의 핵심 개념은 은닉성, 상속, 다형성입니다.
이번 포스팅에서 설명할 것은 '상속'인데, 상속은 객체들을 관리하고 설계하기에 매우 훌륭한 도구입니다.
'상속'은 '파생 클래스'가 '기초 클래스'의 기능을 계승하는 것이라고 설명할 수 있습니다.
일반적으로 상속의 뿌리에는 '베이스 클래스'라는 것이 있고, 베이스 클래스를 상속받는 '파생 클래스'가 존재합니다.
흔히 이것을 '부모'와 '자식'의 관계에 있다고 말하는데요.
부모는 자식에게 상속하고 싶은 함수와 변수를 설정하고,
자식은 부모로부터 상속받은 함수와 변수를 사용할 수 있습니다.
다음 코드로 예시를 보겠습니다.
class Player
{
public:
Player(int x, int y, int id)
: _x(x), _y(y), _playerID(id)
{}
virtual ~Player() {};
void SayHello() { cout << "I'm Player!" << endl; }
protected:
int _x;
int _y;
private:
const int _playerID;
};
class Knight : public Player
{
public:
Knight(int str, Sword sword, int x, int y ,int id)
:Player(x,y,id), _strength(str), _sword(sword)
{}
void SayHello() { cout << "I'm Knight!" << endl; }
private:
int _strength;
Sword _sword;
};
RPG 게임을 하고 있다고 가정합니다.
Player라는 베이스 클래스가 존재하고, Player의 특성을 공유받는 여러 가지 클래스가 있을 수 있죠
기사, 궁수, 마법사, 암살자 등..
Player라는 큰 틀을 베이스로 하여 여러가지 클래스(직업)를 만들 수 있습니다.
이렇게 계층적인 설계를 하면 개발 과정에 있어서 실수할 가능성이 적고, 새로운 기능을 추가하는데 편리하겠죠.
위 코드를 천천히 뜯어보면서 상속에 대한 분석을 해보겠습니다.
접근 지정자
class Player
{
public:
Player(int x, int y, int id)
: _x(x), _y(y), _playerID(id)
{}
virtual ~Player() {};
void SayHello() { cout << "I'm Player!" << endl; }
protected:
int _x;
int _y;
private:
const int _playerID;
};
Player의 접근지정자를 보면
public, protected, private 이렇게 3개가 있습니다.
public의 경우는 어디서든 접근이 가능한 멤버입니다.
다른 함수에서 자유롭게 접근가능하죠
int main()
{
Player p(10, 10, 10);
p.SayHello();
}
protected는 자신의 자식만 접근할 수 있도록 열어주는 겁니다.
외부에는 알리고싶지 않으나 자식은 편하게 접근해서 사용할 수 있도록 합니다.
class Knight : public Player
{
public:
Knight(int str, Sword sword, int x, int y ,int id)
:Player(x,y,id), _strength(str), _sword(sword)
{}
void SayHello() { cout << "I'm Knight!" << endl; }
//이 부분에 주목!
void Move(int x, int y)
{
_x = x;
_y = y;
}
private:
int _strength;
Sword _sword;
};
private는 자식 조차 접근하지 못하고, 자기 자신에서만 접근할 수 있는 멤버입니다.
자식에서 접근하려고 하면 컴파일 오류가 발생합니다.
상속 접근 지정자
class Knight : public Player /* public 상속? */
{
public:
Knight(int str, Sword sword, int x, int y ,int id)
:Player(x,y,id), _strength(str), _sword(sword)
{}
void SayHello() { cout << "I'm Knight!" << endl; }
private:
int _strength;
Sword _sword;
};
접근 지정자의 접근 권한에 따른 보안 강도는 다음과 같습니다
public < protected < private
상속 시 접근 지정자를 정할 수 있는데,
이는 '최소 보안강도'를 정한다고 보면 되겠습니다
최소 보안강도가 public이면,
부모의 public -> public
부모의 protected -> protected
부모의 private -> private
만약 상속 접근 지정자를 protected로 할 경우
부모의 public -> protected
부모의 protected -> protected
부모의 private -> private
public의 보안강도가 올라갑니다.
public의 멤버를 protected 형태로 상속받는다라고 생각하시면 되겠습니다.
만약 상속 접근 지정자를 private로 할 경우
부모의 public -> private
부모의 protected -> private
부모의 private -> private
이 경우 모든 상속을 private 형태로 받습니다.
실제로는 public 상속을 가장 많이 사용합니다.
생성자의 호출 순서
생성자는 기본적으로
부모 -> 자식 -> 자식의 자식 ->...
순서대로 호출합니다.
class Animal
{
public:
Animal() {};
virtual ~Animal() {};
};
class Human : public Animal
{
public:
Human() {};
virtual ~Human() {};
};
class Korean : public Human
{
public:
Korean() {};
virtual ~Korean() {};
};
이런 식으로 상속구조를 보일 때,
Korean k; 객체를 만든다면 내부적으로는
Animal 생성자 -> Human 생성자 -> Korean 생성자
가 순서대로 호출됩니다.
class Animal
{
public:
Animal() {};
virtual ~Animal() {};
};
class Human : public Animal
{
public:
Human() {}; //암시적인 표현
Human() //명시적인 표현
:Animal()
{};
virtual ~Human() {};
};
class Korean : public Human
{
public:
Korean() {}; //암시적인 표현
Korean() //명시적인 표현
:Human()
{};
virtual ~Korean() {};
};
표현을 명시적으로 바꿔보았습니다.
실제로 컴파일러가 생성자를 호출할 시 부모의 기본생성자를 암시적으로 호출합니다.
만약, 부모에 기본 생성자가 따로 정의가 되어있지 않은 경우에는 실제로 명시적인 표현을 써줘야만 합니다.
Knight(int str, Sword sword, int x, int y ,int id)
:Player(x,y,id), _strength(str), _sword(sword)
{}
앞서 본 Player의 멤버 변수에는 초기화가 필요한 'const int' 형식이 있었는데
const int의 경우 기본 생성자로 처리할 수 없습니다.
그렇기 때문에 이런 식으로 명시적인 처리가 필수적으로 요구됩니다.
코딩 관습 중 하나인데, 부모 클래스의 멤버 변수는 자식 클래스에서 직접적으로 초기화하면 안 됩니다.
일반적으로 부모 클래스의 생성자를 호출하여 초기화하는 방법을 사용하는 것이 관례입니다.
소멸자의 호출 순서
소멸자의 호출 순서는 생성자와 정반대입니다.
소멸자 -> 부모 소멸자 -> 부모의 부모 소멸자 ->...
이런 식으로 소멸됩니다.
상속 금지하기 : final
설계 실수를 막기 위해서 절대 상속을 시키고 싶지 않은 클래스가 있다고 한다면
final 문법을 붙여 의도적으로 컴파일 에러를 유도할 수 있습니다.
부모 클래스와 자식 클래스의 변환(캐스팅)
부모 클래스 타입에 대한 참조자, 포인터가 자식 객체를 가리키게 할 수 있습니다.
그러나 역은 성립하지 않습니다.
자식 클래스객체가 부모 클래스 타입에 결합할 수 있는 이유는
자식 클래스는 부모 클래스의 정보를 모두 가지고 있기 때문이죠.
//Korean은 Human은 자식 클래스이다.
Human h;
Korean k;
Human* h1 = &k; //허용
Korean* k1 = h1; //허용되지 않음
여기서 중요한 것은
Human* h1 이 가리키고 있는 클래스는 결국 Korean인데,
h1이 가리키는 클래스의 실제 타입이 무엇인지는 정확하게 판별하지 못합니다.
기본적으로는 Korean 객체를 가리키고 있어도 Human 클래스라고 판단하죠.
그래서 아래와 같은 불상사가 발생합니다.
int main()
{
//Korean은 Human은 자식 클래스이다.
Human h;
Korean k;
Human* h1 = &k; //허용
Korean* k1 = h1; //h1이 가리키고 있는건 Korean 객체 아닌가?
}
h1이 가리키고 있는 것 분명 Korean 객체가 맞지만
컴파일러는 h1을 Human 클래스라고 인식하고 있기 때문에
k1에 h1을 대입하는 행위는 허용되지 않습니다.
이런 경우 '다이내믹 캐스팅'을 이용하면 안전하게 대입할 수 있습니다만,
캐스팅에 대한 내용은 다른 포스팅에 소개하도록 하겠습니다.
int main()
{
//Korean은 Human은 자식 클래스이다.
Human h;
Korean k;
Human* h1 = &k; //허용
Korean* k1 = dynamic_cast<Korean*>(h1); //형변환을 통해서 대입 가능
}
상속 설계를 어떻게 해야 할까? : is-a vs has-a
상속 설계를 할 때 유용한 팁은 질문을 던지는 겁니다.
A 가 B 인가? vs A 가 B를 가지고 있나?
질문 1: Cat은 Animal 인가? vs Cat은 Animal을 가지고 있나?
Cat은 Animal이니까 Animal을 상속해 주는 것이 좋은 설계입니다.
질문 2: Knight는 Sword 인가? vs Knight는 Sword를 가지고 있나?
Knight는 Sword를 가지고 있으니까 상속보다는 멤버 변수로 Sword를 관리하는 것이 좋은 설계입니다.
위 질문은 조금 뻔하지만 설계를 하다 보면 무조건 애매모호한 상황이 나옵니다.
예를 들어서, 게임 캐릭터에게 '중력'을 구현시키려고 합니다.
그러면 경우의 수는 2가지가 있죠
1) 상위 Player 부모 클래스에 'UpdateGravity'라는 함수를 만들어서 중력을 구현한 다음에
MyPlayer라는 자식 클래스를 만들어서 Player 클래스를 상속받는다. (Is-a)
2) GravityComponent라는 클래스를 만들어 플레이어의 좌표를 조작할 수 있는 외부 클래스를 만들어
MyPlayer 가 GravityCompoent를 멤버 변수로 관리하게 한다. (has-a)
사실 이 문제에 대한 답은 없습니다.
실제로 상용 게임 엔진 중에 1번으로 구현하는 엔진도 있고, 2번으로 구현하는 엔진도 있기 때문이죠.
기본적으로는 is-a vs has-a 질문을 던져서 상속 설계를 하되
애매모호한 부분이 등장하면 창의력을 발휘해서 상속을 할 건지, 아니면 멤버 변수로 관리할 것인지
잘 생각해야 합니다.
'언어 > C, C++' 카테고리의 다른 글
[C++] 캐스팅 4총사 (1) | 2024.01.08 |
---|---|
[C++] 다형성 (0) | 2024.01.07 |
[C++] 클래스가 암시적으로 정의하는 함수들 (0) | 2024.01.06 |
[C++] 연산자 오버로딩 (0) | 2024.01.06 |
[C++] C++에서 구조체 vs 클래스, 차이가 뭘까? (0) | 2023.12.28 |