[C++] 객체 지향 프로그래밍 - 상속성, 은닉성, 다형성

Taeil Nam·2022년 10월 24일
0

C++

목록 보기
4/13
post-thumbnail

상속성 (Inheritance)

  • 말 그대로 물려받는 것. (자식이 부모한테 상속 받는 것과 동일)
  • 클래스를 생성할 때, 특정 클래스의 값을 물려받아 사용.
  • 값을 물려주는 클래스 = 부모 클래스. (parent)
  • 값을 물려받는 클래스 = 자식 클래스. (child)
  • 자식 클래스들이 중복으로 사용하는 값들을 부모 클래스에 만들어서 중복 제거 가능.
  • 클래스를 설계하면서 계속 부모 클래스가 늘어날 수 있음.

예제 코드

#include <iostream>

using namespace std;

class Player
{
public:
	void Attack()
	{
		cout << "플레이어가 공격합니다. 공격력 = " << _damage << endl;
	}

	void Die()
	{
		_hp = 0;
		cout << "플레이어가 죽었습니다. HP = " << _hp << endl;
	}

public:
	int _hp = 100;
	int _damage = 30;
	int _defence = 10;
};

class Knight : public Player // Player 클래스를 상속 받음.
{
	// Player 클래스와 동일한 상태.
};

int main()
{
	Knight knight; // Knight 클래스의 객체인 knight 선언.

	knight.Attack(); // Knight의 멤버 함수 Attack() 호출.
	knight.Die();	// Knight의 멤버 함수 Die() 호출.

	return 0;
}

결과

  • 부모 클래스의 멤버 함수와 멤버 변수 사용 가능.

상속을 해야할 때

  1. is A? = Yes 일 경우 상속.
  2. has A? = Yes 일 경우 상속보다는 멤버로 가지도록 함.

ex1) Knight is Player?

  • Yes -> Knight에 Player 상속.

ex2) Player is Inventory?

  • No -> 상속 안함.

ex3) Player has Inventory?

  • Yes -> Player에 Inventory를 멤버로 추가.

상속시 메모리 크기

  • 상속시 메모리 크기 = 부모 클래스의 메모리 크기 + 자식 클래스의 메모리 크기.
  • 자식 클래스가 부모 클래스의 값들을 상속 받기 때문에, 부모 클래스 멤버들의 메모리를 먼저 할당 받고 거기에 자식 클래스 멤버들의 메모리를 더함.

다중 상속

  • 어지간하면 사용하지 않는 것을 권고.
  • 다중 상속 사용시 꼬일 가능성이 있기 때문.
  • 사용 안한다고 생각하고 넘어가자.

은닉성 (Data Hiding)

  • 캡슐화 (Encapsulation) 라고도 함.
  • 개념적으로는 데이터를 캡슐로 감싸서 외부로부터 보호한다는 느낌.
  • C++에서는 클래스의 멤버 변수와 멤버 함수의 공개 범위를 설정하는 것.

멤버 접근 지정자

  • 멤버 변수와 멤버 함수의 공개 범위를 어떻게 설정 할지.
  1. Public
    • 공개적.
    • Class 외부에서 사용 가능.
  2. Private
    • 개인적.
    • Class 내부에서만 사용 가능.
  3. Protected
    • 중립적.
    • 자식 클래스만 사용 가능.

상속 접근 지정자

  • 자식 클래스들한테 상속을 어떻게 할지.
  • 거의 다 public을 사용하며, 다른 것들은 아주 드물게 사용.
  • 그냥 public을 사용한다고 생각하면 됨.
  1. Public
    • 공개적 상속.
  2. Private
    • 나까지만 상속.
  3. Protected
    • 보호 받는 상속.

예제 코드

#include <iostream>

using namespace std;

class Player
{
public: // Class 외부에서 사용 가능.
	void Attack()
	{
		cout << "플레이어가 공격합니다. 공격력 = " << _damage << endl;
	}
	void GetHit()
	{
		_hp -= 100;
		cout << "플레이어가 피격 당했습니다! 남은 HP = " << _hp << endl;
		if (_hp <= 0)
		{
			_hp = 0;
			Die();
		}
	}

private: // 자기 자신과 자식 클래스에서 사용 가능.
	void Die()
	{
		_hp = 0;
		cout << "플레이어가 죽었습니다." << endl;
	}

protected: // Class 내부에서만 사용.
	int _hp = 100;
	int _damage = 30;
	int _defence = 10;
};

class Knight : public Player
{
public: // Class 외부에서 사용 가능.
	Knight() // 기본 생성자.
	{
		cout << "Knight 생성!" << endl;
		SetDamage(); 
	}

private: // Class 내부에서만 사용 가능.
	void SetDamage()
	{
		cout << "Knight의 공격력을 50으로 설정합니다!" << endl;
		_damage = 50;
	}
};

int main()
{
	Knight knight;

	knight.Attack();
	//knight.Die(); // Die() 함수는 protected 이기 때문에 외부에서 사용 불가능.
	//knight._hp = 10000; // _hp 변수는 private 이기 때문에 외부에서 사용 불가능.
	//knight.SetDamage = 99999; // SetDamage() 함수는 pricate 이기 떄문에 외부에서 사용 불가능.
	knight.GetHit();

	return 0;
}

결과

  • Class 외부에서는 knight 객체 생성, Attack(), GetHit() 만 호출.
  • Class 내부적으로 knight 공격력 설정, Die() 호출.

다형성 (Polymorphism)

  • 부모와 자식 클래스의 함수 선언은 동일한데, 기능이 다르게 동작하는 것.
  • 부모 클래스의 멤버 함수를 자식 클래스에서 재정의하여 사용 = 오버라이딩.

정적 바인딩, 동적 바인딩

  • 오버라이딩 사용을 위해 알아야 되는 개념.
  • 바인딩 (Binding) = 함수를 호출할 때 호출할 함수의 메모리를 알려주는 것.
  1. 정적 바인딩 (Static Binding)
    • 컴파일 시점에 호출될 함수를 미리 결정. (바인딩)
    • 일반 함수에서 사용.
  2. 동적 바인딩 (Dynamic Binding)
    • 런타임 시점에 호출될 함수를 결정.
    • 실행 파일을 만들 때 바인딩을 하지 않고, 호출될 함수 주소를 저장할 메모리 공간을 가지고 있다가 런타임에 결정.
    • 수행 속도 저하 및 메모리 공간의 낭비 단점 때문에, 가급적 정적 바인딩 사용.
    • 동적 바인딩을 사용하는 이유 : 포인터 자료형에 상관 없이, 참조된 객체의 재정의 함수를 호출 가능.
    • 가상 함수에서 사용.

오버라이딩 (Overriding)

  • 부모 클래스의 멤버 함수를 자식 클래스에서 재정의.
[오버로딩 vs 오버라이딩]
- 오버로딩 (Overloading) = 동일한 이름의 함수들을 매개 변수의 자료형이나 개수를 다르게 하여 같이 사용할 수 있게 하는 것.
- 오버라이딩 (Overriding) = 부모 클래스의 멤버 함수를 자식 클래스에서 재정의하여 사용하는 것.
! 오버로딩과 오버라이딩은 다른 개념이니까 헷갈리지 말자!

정적 바인딩을 사용한 오버라이딩 (부적절)

  • 아래 예시를 보자.
#include <iostream>

using namespace std;

class Player
{
public:
	void Move() { cout << "Move Player !" << endl; } // Player 객체를 위한 Move() 함수.

public:
	int _hp;
};

class Knight : public Player
{
public:
	void Move() { cout << "Move Knight !" << endl; } // Knight 객체를 위한 Move() 함수.

public:
	int _stamina;
};

void MovePlayer(Player* player) // 정적 바인딩 사용.
{
	player->Move(); // Player의 Move() 함수가 호출되도록 정적 바인딩 됨.
}

void MoveKnight(Knight* knight) // 정적 바인딩 사용.
{
	knight->Move(); // Knight의 Move() 함수가 호출되도록 정적 바인딩 됨.
}

int main()
{
	Player p;
	Knight k;

	MovePlayer(&p);
	MoveKnight(&k);

	return 0;
}
  • Player 객체를 위한 MovePlayer() 함수도 만들고, Player 클래스의 자식 클래스인 Knight 객체를 위한 MoveKnight() 함수도 만들어야 함.
  • 만약, Player 클래스의 자식 클래스로 Mage, Archer 등등 더 추가된다면, 기능이 동일한 함수가 클래스 개수만큼 계속 늘어나게 됨.
  • 또한, MovePlayer() 함수만 사용해서 사용한다고 하면 Player의 Move() 함수만 호출되므로 다형성이 수행되지 않음.
  • 해당 문제 해결을 위해 동적 바인딩(가상 함수) 사용.

동적 바인딩을 사용한 오버라이딩 (적절)

  • 부모 클래스에서 가상 함수로 선언할 멤버 함수 앞에 "virtual"을 넣어줌.
  • 자식 클래스에서 가상 함수에 "override"를 넣어주면, 부모 클래스에서 가상 함수에 "virtual"을 빼먹었는지 확인 가능. (C++11)
#include <iostream>

using namespace std;

class Player
{
public:
	virtual void Move() { cout << "Move Player !" << endl; }
	// 멤버 함수 앞에 "virtual"을 사용하여, 가상 함수로 선언.
	// 부모 클래스에서 가상 함수로 선언시, 자식 클래스에서 "virtual" 없어도 가상 함수로 선언됨.
	// 한번 가상 함수 = 영원한 가상 함수.

public:
	int _hp;
};

class Knight : public Player
{
public:
	virtual void Move() override { cout << "Move Knight !" << endl; }
	// 부모 클래스인 Player에서 가상 함수로 선언했으므로, "virtual" 없어도 알아서 가상 함수로 선언됨.
    // 가상 함수라면, 명시적으로 가상 함수임을 나타내는 "virtual"을 붙여주는 것이 좋음.
    // 자식 클래스에서는 부모 클래스에서 가상 함수에 "virtual"을 빼먹었는지 확인해주는 "override" 사용.

public:
	int _stamina;
};

void MovePlayer(Player* player) // 동적 바인딩 사용.
{
	player->Move();	// 함수를 호출한 실제 객체의 Move() 함수가 호출됨.
}

int main()
{
	Player p;
	Knight k;

	MovePlayer(&p);
	MovePlayer(&k);

	return 0;
}

가상 함수 상세 내용

가상 함수 테이블 (Virtual Function Table)

  • 런타임에 가상 함수를 바인딩할 때 사용.
  • 객체마다 각각의 vftable을 가짐.
  • vftable은 객체가 사용하는 각 가상 함수들의 메모리 주소를 배열로 저장.
  • vftable = [가상 함수1의 메모리 주소][가상 함수2의 메모리 주소]...
  • vftable의 각 원소의 크기 = 메모리 주소를 저장하므로, 포인터와 동일. (x86 = 4 Bytes, x64 = 8 Bytes)
참고
- vftable을 만드는 곳 = 생성자의 선처리 부분.

vftable 디버깅

  • 객체 k, p가 서로 다른 vftable을 가지고 있으며, 각 객체의 바인딩 주소가 다른 것 확인.
  • 객체 k가 가상 함수 호출시, 객체 k의 vftable에서 알려주는 주소(0x00007ff7de81148d)의 함수를 호출.
  • 객체 p가 가상 함수 호출시, 객체 p의 vftable에서 알려주는 주소(0x00007ff7de811497)의 함수를 호출.

순수 가상 함수 (추상 클래스)

  • 함수 정의 부분 없이 선언만 하는 가상 함수.
  • 순수 가상 함수를 만들면, 자식 클래스에서 무조건 해당 함수 재정의 필요.
  • 순수 가상 함수를 가지고 있는 클래스 = 추상 클래스.
  • 추상 클래스는 함수의 정의 부분이 없으므로, 직접 객체를 만들 수 없음.
  • 순수 가상 함수를 재정의한 자식 클래스에서 객체 생성 가능.

예제 코드

#include <iostream>

using namespace std;

class Player
{
public:
	virtual void Attack() = 0;
	// 순수 가상 함수 선언. (함수 뒤에 "= 0" 을 붙여줌.)
    // Player 클래스 = 추상 클래스가 됨.

public:
	int _hp;
};

class Knight : public Player
{
public:
	virtual void Attack()
	{
		cout << "Knight Attack!" << endl;
	}

public:
	int _stamina;
};

int main()
{
	//Player p; // Player = 추상 클래스 이므로 직접 객체 생성 불가능.
	Knight k; // Knight = 부모 클래스 Player의 순수 가상 함수 Attack()을 재정의 했으므로 객체 생성 가능.

	k.Attack(); // 객체 k의 Attack() 함수 호출.

	return 0;
}

결과


! 부모 클래스의 소멸자를 가상 함수(virtual)로 만들어야 하는 이유

  • 자식 클래스를 부모 클래스 타입으로 사용하는 상황에서 소멸을 시킬 경우, 자식 클래스의 소멸자가 아닌 부모 클래스의 소멸자가 호출 됨.
  • 실제 객체는 자식 클래스이지만, 부모 클래스 타입 으로 사용했으므로, 정적 바인딩으로 인해 부모 클래스의 소멸자가 호출 됨.
  • 자식 클래스의 소멸자에서 특정 메모리를 반납하는 경우, 자식 클래스의 소멸자가 호출되지 않아 메모리 누수가 발생할 수 있음.
  • 위의 문제 해결을 위해, virtual 키워드를 사용하여 부모 클래스의 소멸자를 가상 함수로 선언.

virtual 미사용

예제 코드

#include <iostream>

using namespace std;

class Player	// 부모 클래스 Player
{
public:
	Player() { cout << "Monster()" << endl; };		// Player 생성자
	~Player() { cout << "~Monster()" << endl; };	// Player 소멸자

public:
};

class Pet	// Knight의 멤버 클래스 Pet
{
public:

public:
	int _hp = 100;
};

class Knight : public Player	// 자식 클래스 Knight
{
public:
	Knight() { cout << "Knight()" << endl; _pet = new Pet(); }	// Knight 생성자
	~Knight() { cout << "~Knight()" << endl; delete _pet; }		// Knight 소멸자
public:
	Pet* _pet;
};

int main()
{
	Player* player = new Knight;	// Knight 객체를 Player 타입으로 사용
	delete player;	// player 소멸 (실제 객체 = Knight)
	
	return 0;
}

결과

  • 자식 클래스 Knight의 소멸자가 호출 되지 않음.
  • 동적 할당 받은 _pet 변수의 메모리 누수 발생.

virtual 사용 (가상 함수)

예제 코드

#include <iostream>

using namespace std;

class Player	// 부모 클래스 Player
{
public:
	Player() { cout << "Monster()" << endl; };		// Player 생성자
	virtual ~Player() { cout << "~Monster()" << endl; };	// Player 소멸자 (동적 바인딩)

public:
};

class Pet	// Knight의 멤버 클래스 Pet
{
public:

public:
	int _hp = 100;
};

class Knight : public Player	// 자식 클래스 Knight
{
public:
	Knight() { cout << "Knight()" << endl; _pet = new Pet(); }	// Knight 생성자
	~Knight() { cout << "~Knight()" << endl; delete _pet; }		// Knight 소멸자
public:
	Pet* _pet;
};

int main()
{
	Player* player = new Knight;	// Knight 객체를 Player 타입으로 사용
	delete player;	// player 소멸 (실제 객체 = Knight)
	
	return 0;
}

결과

  • 자식 클래스 Knight의 소멸자 호출 됨.
  • 동적 할당 받은 _pet 변수의 메모리 반납 완료. (메모리 누수 방지)

0개의 댓글