[C / C++] 객체 지향 - 상속성, 은닉성, 다형성
객체 지향 프로그래밍(OOP, Object-Oriented Programming)
- 객체 지향 프로그래밍에서는 모든 데이터를 객체로 취급한다.
- 객체란? 플레이어, 몬스터, Game Room 등
- 또한, 이와 같은 객체를 만들어내기 위한 틀과 같은 개념이 바로 클래스(Class)이다.
객체 지향 프로그래밍의 특징
- 객체 지향 프로그래밍이 가지는 특징은 다음과 같다.
1. 상속성
2. 은닉성(캡슐화)
3. 다형성
--------------------------------------------------------------------------------------------------------------------------------
클래스(Class)
- C++에서 클래스란 구조체의 상위 호환으로 이해할 수 있지만, 구조체와 클래스의 차이는 기본 접근 지정자의 차이일 뿐 나머지는 거의 같다.
- C++에서 클래스는 구조체와 마찬가지로 사용자가 정의할 수 있는 일종의 타입이다.
- 따라서 클래스를 사용하기 위해서는 해당 클래스 타입의 객체를 선언해야 한다.
- 이렇게 선언된 해당 클래스 타입의 객체를 인스턴스(Instance)라고 하며, 메모리에 대입된 객체를 의미한다. (클래스 하나에 여러 개 생성가능)
class Knight
{
public: // 접근 지정자
// 멤버 함수 선언
void Move(int y, int x);
void Attack();
public: // 접근 지정자
// 멤버 변수
int _hp;
int _attack;
};
접근 지정자는 객체 지향 프로그래밍의 특징인 은닉성에 관한 내용이다.
멤버 변수는 프로퍼티(property), 멤버 함수를 메서드(method)라고도 한다.
멤버 변수는 다른 일반 변수와 헷갈리지 않게 변수명을 다르게 해 주면 좋다. (_hp, m_hp, Hp 등등)
void Knight::Die(int hp)
{
if (_hp <= 0)
cout << "Knight Die" << endl;
}
멤버 함수는 클래스 선언 안이나 밖에서 모두 정의할 수 있다.
클래스 선언 밖에서 멤버 함수를 정의할 때에는 ::를 사용하여 어디 클래스 소속인지를 명시해야 한다.
--------------------------------------------------------------------------------------------------------------------------------
생성자 (Constructor)와 소멸자 (Destructor)
- C++에서는 객체의 생성과 동시에 생성자라는 멤버 함수를 제공한다.
- 생성자는 여러 개 존재할 수 있다.
- 객체의 수명이 끝나면 생성자의 반대 역할을 수행할 소멸자라는 멤버 함수를 제공한다.
- 소멸자는 단 한 개만 존재할 수 있다.
- 생성자는 호출 됐을 때 가장 먼저 실행되고, 소멸자는 객체의 수명이 끝나면 자동으로 호출되며, 사용이 끝난 객체를 정리해 준다.
- 생성자의 종류에는 크게 3가지가 있다.
클래스 생성자, 소멸자는 클래스 선언의 public 영역에 포함되어야 한다.
[1] 기본 생성자
- 아무 인자도 받지 않는 생성자이다.
- 생성자를 아무것도 적지 않아도 기본 생성자는 컴파일러에 의해 자동으로 만들어진다.
- 그러나, 우리가 명시적으로 아무 생성자를 하나 이상 만들면, 자동으로 만들어지는 [기본 생성자]는 만들어지지 않는다.
class Knight
{
public:
// [1] 기본 생성자 (인자가 없음)
Knight()
{
cout << "Knight() 기본 생성자 호출" << endl;
_hp = 100;
}
public:
int _hp;
}
[2] 복사 생성자
- 자신과 같은 클래스 타입의 다른 객체에 대한 참조를 인수로 전달받아, 그 참조를 가지고 자신을 초기화하는 생성자이다.
- 일반적으로 똑같은 데이터를 지닌 객체가 생성된다.
- 복사 생성자에 대한 자세한 설명은 얕은 복사 vs 깊은 복사에서 설명한다.
class Knight
{
public:
// [2] 복사 생성자 (자기 자신의 클래스 참조 타입을 인자로 받음)
Knight(const Knight& Knight)
{
_hp = Knight._hp;
}
public:
int _hp;
}
[3] 기타 생성자
- 인자를 1개 이상 받는 생성자이다.
- 이 중에서 인자를 1개만 받는 [기타 생성자]를 [타입 변환 생성자]라 한다.
- [타입 변환 생성자]란 내가 명시적으로 타입을 변환하지 않았는데, 컴파일러가 알아서 타입을 변환시키는 것을 말한다.
- ex) Knight k1 = 1; k1 변수에는 1이라는 정수가 들어갈 수가 없지만, 컴파일러가 암시적으로 타입을 변환시켜 진행한다.
- 생성자 앞에 explicit를 붙이고 생성자를 정의하면 암시적 타입 변환을 못하게 한다. (명시적으로만 가능)
class Knight
{
public:
// [3] 기타 생성자 (인자를 1개 이상 받음)
Knight(int hp, int attack)
{
_hp = hp;
_attack = attack;
}
explicit Knight(int hp) // 암시적 타입 변환을 못하게 함
{
cout << "Knight(int) 생성자 호출" << endl;
_hp = hp;
}
public:
int _hp;
int _attack;
}
- 소멸자
- 클래스 이름 앞에 ~를 붙이면 소멸자를 뜻한다.
- 한 개만 존재할 수 있다.
class Knight
{
public:
Knight() { };
// 소멸자
~Knight()
{
cout << "~Knight() 소멸자 호출" << endl;
}
public:
int _hp;
int _attack;
}
--------------------------------------------------------------------------------------------------------------------------------
1. 상속성 (Inheritance)
- 상속성이란 기존에 정의되어 있는 클래스의 멤버 변수와 멤버 함수를 물려받아서 새로운 클래스에서 사용할 수 있는 것이다.
- 물려주는 클래스를 부모 클래스, 물려받는 클래스를 자식 클래스라고 부른다. (상위, 하위클래스 등 다양하게 부르기도 함)
- 상속성을 이용하여 기존에 작성하던 클래스를 재활용할 수 있고, 똑같은 멤버 함수나 변수를 중복해서 쓸 부분을 제거할 수 있다.
class Player // 부모 클래스
{
public:
int Attack() { cout << "Player Attack" << endl; }
public:
int _hp;
int _mp;
};
class Knight : public Player // 자식 클래스
{
public:
};
int main()
{
Knight k;
k.Attack();
k._hp = 100;
return 0;
}
객체 k가 부모 클래스의 멤버 변수와 멤버 함수를 사용할 수 있다.
생성자와 소멸자는 클래스마다 존재하고, 이건 상속을 해주지 않기 때문에 개별로 만들어야 한다.
여기서, Player를 상속받은 Knight를 호출하면 누구의 생성자가 호출될까?
정답은 둘 다 호출된다. (부모 생성자 -> 자식 생성자 -> 자식 소멸자 -> 부모 소멸자 순) 마트료시카 같은 느낌으로 호출된다.
상속 시 메모리 크기는 부모 클래스의 메모리 크기 + 자식 클래스의 메모리이다.
※ 상속을 해야 할 때와 멤버 변수로 가져야 할 때
1. is A? = Yes 일 경우 상속.
2. has A? Yes 일 경우 상속보다는 멤버 변수로 가진다.
ex) Knight is a Player? 나이트는 플레이어냐? 맞다.
- Knight에 Player 상속
ex) Knight is a Item? 나이트는 아이템이냐? 아니다.
ex) Knight has a item? 나이트는 아이템을 가지고 있냐? 맞다.
- Knight에 item을 멤버 변수로 추가
--------------------------------------------------------------------------------------------------------------------------------
2. 은닉성 (Data Hiding)
- 캡슐화 (Encapsulation)라고도 한다.
- 개념적으로는 숨기거나 보호하고 싶은 데이터는 외부로부터 숨기겠다는 뜻이다.
- 은닉성을 사용하는 이유는 정말 위험하고 건드리면 안 되는 데이터일 경우, 다른 경로로 접근하길 원하는 경우 등이 있다.
- 은닉성에는 2 분류의 접근 지정자가 있다.
멤버 접근 지정자
- 멤버 변수와 멤버 함수의 공개 범위를 어떻게 설정할지.
1. Public
- 공개적
- 전체 공개, 다 사용할 수 있다.
2. Protected
- 보호적
- 일부 공개, 자신과 상속된 자손 클래스까지 사용 가능. (부모에게 상속받은 클래스는 사용 가능)
3. Private
- 개인적
- 비공개, 자신의 클래스에서만 사용 가능
상속 접근 지정자
- 자식 클래스한테 어떻게 상속할지
- 웬만한 경우에선 public 사용 (드물게 다른 접근 지정자도 사용한다)
1. Public
- 공개적
- 부모 클래스의 기능을 그대로 물려줌.
2. Protected
- 보호적
- 나의 자식 클래스한테만 물려줌.
3. Private
- 개인적
- 내 클래스까지만 사용하고 자식한테는 안 물려줌.
class Player
{
public:
int Attack() { cout << "Player Attack" << endl; }
public:
int _hp;
int _attack;
};
class Knight : public Player // 상속 접근 지정자
{
protected:
int _defence;
};
class Mage : public Knight
{
private:
int _mp;
};
class Archer : public Mage
{
int Hit()
{
_defence -= 10;
// _mp = 0; Mage 클래스에서 private로 지정했기 때문에 없음
}
};
int main()
{
Knight k;
k.Attack();
Mage m;
m._hp = 200;
return 0;
}
--------------------------------------------------------------------------------------------------------------------------------
3. 다형성 (Polymorphism)
- 개념적으론 부모와 자식의 함수 선언은 동일한데, 기능은 다르게 작동하는 것이다.
바인딩 (Binding)
- 프로그램 소스에 쓰인 각종 내부 요소, 이름 식별자들에 대해 값 또는 속성을 확정한 과정이다.
1. 정적 바인딩 (Static Binding)
- 컴파일 시점에서 호출될 함수를 미리 결정한다. (바인딩)
- 일반 함수에서 사용한다.
2. 동적 바인딩 (Dynamic Binding)
- 런타임 시점에 호출될 함수를 결정한다.
- 실행 파일을 만들 때 바인딩을 하지 않고, 호출될 함수 주소를 저장할 메모리 공간에 가지고 있다가 런타임에 결정된다.
- 수행 속도 저하 및 메모리 공간 낭비 때문에 가급적 정적 바인딩을 사용한다.
- 동적 바인딩을 사용하는 이유? 포인터 자료형에 상관없이, 참조된 객체의 재정의 함수를 호출 가능하다.
- 가상 함수에서 사용한다.
오버로딩 (Overloading), 오버라이딩 (Overriding)
1. 오버로딩 (Overloading)
- 오버로딩 = 함수 중복 정의 = 함수 이름의 재사용
- 동일한 이름의 함수들을 매개 변수의 자료형이나 개수를 다르게 하여 같이 사용하는 것이다.
class Player
{
public:
void Attack() { cout << "Player() Attack!" << endl; }
void Attack(int hp) { cout << "Player(int hp) Attack!" << endl; }
public:
int _hp = 10;
};
2. 오버라이딩 (Overriding)
- 오버라이딩 = 재정의
- 부모 클래스의 멤버 함수를 자식 클래스에서 재정의하여 사용하는 것이다.
여기서, 이 함수의 원본이 어떤 함수인지 모르기 때문에 오버라이딩 하는 함수는 뒤에 override를 붙인다.
class Player
{
public:
void Attack() { cout << "Player() Attack!" << endl; }
};
class Knight : public Player
{
public:
void Attack() override { cout << "Knight() Attack!" << endl; }
};
--------------------------------------------------------------------------------------------------------------------------------
오버라이딩 정적 바인딩.ver
class Player
{
public:
void Attack() { cout << "Player() Attack!" << endl; }
};
class Knight : public Player
{
public:
void Attack() override { cout << "Knight() Attack!" << endl; }
};
void PlayerAttack(Player* player) // 정적 바인딩 (일반적인 함수) Player 버전
{
player->Attack();
}
void KnightAttack(Knight* knight) // 정적 바인딩 (일반적인 함수) Knight 버전
{
knight->Attack();
}
int main()
{
Player p;
Knight k;
PlayerAttack(&p); // 본인 함수기 때문에 문제 x
PlayerAttack(&k); // Knight is a Player? Yes 상속받았기 때문에 문제 x
//KnightAttack(&p); Player is a Knight? No 자식 클래스 함수는 못 사용함
KnightAttack(&k); // 본인 함수기 때문에 문제 x
return 0;
}
/*실행 결과
Player() Attack!
Player() Attack!
Knight() Attack!
*/
- 가장 상위 클래스의 함수는 부모 클래스가 자식 클래스에게 상속해 주었기 때문에 부모와 자식 모두 사용할 수 있다.
- 반면, 자식 클래스의 함수를 사용하려면 자식 클래스 별로 함수를 모두 따로 만들어 주어야 한다.
- 또한, 가장 상위 클래스의 함수를 사용한다고 해서 상속받은 자식 클래스마다 개별로 함수가 바뀌지 않고 모두 Player의 Attack()만 호출되므로 다형성이 수행되지 않음.
- 해당 문제를 해결하기 위해 동적 바인딩(가상 함수)을 사용한다.
오버라이딩 동적 바인딩.ver
class Player
{
public:
virtual void VAttack() { cout << "Player() VAttack!" << endl; }
// 멤버 함수 앞에 "virtual"을 사용하여 가상 함수로 선언
// 부모 클래스에서 가상 함수 선언 시, 자식 클래스에서 "virtual"을 붙히지 않아도 가상 함수로 선언한다.
// 한 번 가상 함수는 영원한 가상 함수이다.
};
class Knight : public Player
{
public:
virtual void VAttack() override { cout << "Knight() VAttack!" << endl; }
// 부모 클래스에서 "virtual"을 선언했으므로 오버라이딩 하는 자식 클래스에는 안적어도 됨.
// 하지만, 가상 함수임을 표시하기 위해 붙혀주는게 보기에 좋음
};
void PlayerAttack(Player* player) // 동적 바인딩 (가상 함수)
{
player->VAttack(); // 함수를 호출한 실제 객체의 Attack() 함수가 호출된다.
}
int main()
{
Player p;
Knight k;
PlayerAttack(&p);
PlayerAttack(&k);
return 0;
}
/*실행 결과
Player() VAttack!
Knight() VAttack!
*/
부모 클래스에서 가상 함수로 선언할 멤버 함수 앞에 virtual을 넣어준다. (자식 클래스에서 재정의할 함수임)
즉, 자식 클래스에서 부모 클래스의 멤버 함수 중에 재정의하여 사용할 멤버 함수가 있다면, 가상 함수로 변환해서 사용해줘야 한다.
--------------------------------------------------------------------------------------------------------------------------------
가상 함수 테이블 (Virtual Function Table)
- 런타임에 가상 함수를 바인딩할 때 사용한다.
- 가상 함수를 사용하는 순간 생성자의 선처리 부분에서 vftable이 생긴다. vftable은 각각의 객체마다 가진다.
- vftable은 객체가 사용하는 각 가상 함수들의 메모리 주소를 배열로 저장하고 있다. [가상 함수 1] [가상 함수 2]...
- vftable의 메모리 크기는 포인터와 동일하게 배열 1개당 (32bit = 4byte, 64bit = 8byte)의 크기를 가진다.
--------------------------------------------------------------------------------------------------------------------------------
순수 가상 함수 (추상 클래스)
- 함수 정의 부분 없이 선언만 하는 가상 함수이다.
- 순수 가상 함수를 만들면, 자식 클래스에서 무조건 해당 함수를 재정의 해야 한다. (부모 클래스에선 그냥 예약만 걸어두는 느낌)
- 순수 가상 함수를 1개 이상 가지고 있는 클래스 = 추상 클래스이다.
- 추상 클래스는 함수의 정의 부분이 없으므로 직접 객체를 만들 수 없고, 상속받은 클래스에서 구현해야만 사용할 수 있다.
class Player
{
public:
virtual void VAttack() = 0;
// 순수 가상 함수 선언 (함수 뒤에 = 0 을 붙혀준다) (Modern C++ 이후로는 = abstract를 붙혀도 된다.)
// Player 클래스 = 추상 클래스가 된다.
};
class Knight : public Player
{
public:
virtual void VAttack() override { cout << "Knight() VAttack!" << endl; }
};
void PlayerAttack(Player* player)
{
player->VAttack();
}
int main()
{
//Player p; // Player 클래스는 추상 클래스이므로 직접 객체 생성 불가능
Knight k;
k.VAttack(); // 객체 k의 VAttack() 함수 호출
return 0;
}
/*실행 결과
Knight() VAttack!
*/
--------------------------------------------------------------------------------------------------------------------------------
클래스 멤버 변수 초기화 [초기화 리스트 (Member initializer lists)]
멤버 변수를 초기화하는 방법? / 멤버 변수를 왜 초기화해야 할까?
- 멤버 변수 초기화에는 다양한 방법이 존재한다.
- 기본 멤버 변수 값에는 쓰레기값이 저장되어 있다.
- 초기화하지 않은 변수 사용에 대한 버그 예방을 할 수 있다.
- 초기화하지 않은 변수가 포인터 등 주소값이 연루되어 있을 경우를 방지할 수 있다.
멤버 변수 초기화 방법 3가지
1. 생성자 내에서 초기화한다.
2. 생성자 내에서 초기화 리스트로 초기화한다. (Member initializer lists)
3. (C++11 이후) 멤버변수 정의 시 초기화한다.
1. 생성자 내에서 초기화
- 생성자 내에서 클래스 멤버 변수에 값을 직접 초기화할 수 있다.
- 기본 생성자는 물론, 파라미터가 다른 생성자를 오버로딩해서 값을 받아 초기화할 수도 있다.
class Player
{
public:
Player() // 기본 생성자에서 초기화
{
_hp = 100;
_attack = 20;
}
Player(int hp, int attack) // 오버로딩한 생성자
{
_hp = hp;
_attack = attack;
}
public:
int _hp;
int _attack;
};
2. 생성자 내에서 초기화 리스트로 초기화한다.
- 말 그대로 생성자 내에서 초기화를 리스트로 여러 개 하는 것이다.
- 그냥 생성자 내에서 하면 되지 않느냐 할 수 있지만, 멤버 타입이 클래스 경우 차이가 난다.
- 일단, 상속 관계에서 원하는 부모 생성자를 호출할 때 필요하다.
- 일반적인 멤버 변수에서는 별 차이 없지만, 정의함과 동시에 초기화가 필요한 경우 (참조 타입, const 타입)에 사용해야 한다.
- 사용법은 생성자 함수 옆에 :을 붙여서 사용한다.
class Player
{
public:
Player() { };
Player(int id) { };
};
class Inventory
{
public:
Inventory() { }
Inventory(int size)
{
_size = size;
}
public:
int _size = 10;
};
class Knight : public Player
{
public:
Knight() : Player(1), _hp(100), _inventory(20), _hpRef(_hp), _hpconst(10) // 초기화 리스트 (원하는 부모 생성자 선택 및 멤버 변수 초기화)
{
_hp = 100; // 생성자 내에서 초기화
}
public:
int _hp; // 초기화를 해주지 않으면 쓰레기값이 들어감
Inventory _inventory;
// 멤버 변수 타입이 클래스인 것을 초기화 할 경우에는 기본 생성자일 경우에는 상관없지만 인자가 있는 생성자면 생성자 내에서 초기화
// 할 경우 (기본 생성자, 기타 생성자) 총 2번 생성자가 호출되기에 초기화 리스트에서 초기화 해줘야 정의함과 동시에 초기화가 된다.
int& _hpRef;
const int _hpconst;
// 참조와 const 타입의 경우 생성자 내에서 초기화 하면 이미 생성된 변수를 바꾸는 것이므로 에러가 난다.
// 이 경우는 멤버 변수 정의시 초기화 하거나, 초기화 리스트에서 초기화 해야 한다.
};
3. (C++ 11 이후) 멤버변수 정의 시 초기화한다.
- 이 방법은 일반적으로 일반 변수에 값을 넣는 방법과 동일하다 (int a = 10)
- 이것을 클래스 내에서 멤버 변수 정의에 사용해 주면 된다.
- C++ 11 이후부터 사용이 가능해졌다.
class Player
{
public:
int _hp = 100;
int _attack = 10;
};
어떤 방법을 사용해서 멤버 변수를 초기화하던 사실 상관없다.
초기화 방법 3가지의 우선순위 : 초기화 리스트 > 멤버 변수 정의 시 초기화 > 생성자 내에서 초기화 순서이다.
--------------------------------------------------------------------------------------------------------------------------------
연산자 오버로딩 (Operator Overloading)
- 연산자 오버로딩이란 우리가 사용하는 연산자들 +, -, *, / 에서부터 ++, --, [], <, >, ==, = 등 다양한 연산자들을 재정의해서 사용할 수 있게 해주는 방법이다.
- 컴파일러는 객체의 연산을 이해하지 못하기 때문에, 객체끼리의 기본적인 연산을 직접 정의해서 객체끼리의 연산을 편하게 만드는 것이다.
연산자 오버로딩을 하려면 일단 [연산자 함수]를 정의해야 한다.
함수도 멤버 함수 vs 전역 함수가 존재하는 것처럼, 연산자 함수도 두 가지 방식으로 만들 수 있다.
1. 멤버 연산자 함수
- a 연산자 b 형태에서 왼쪽을 기준으로 실행된다. (a가 클래스 타입이어야 가능, a를 '기준 피연산자' 라고 함)
- a(기준 피연산자)가 클래스가 아니면 사용하지 못함.
2. 전역 연산자 함수
- a 연산자 b 형태라면 a, b 모두를 연산자 함수의 피연산자로 만든다.
상황에 따라 적절한 방식을 채택하여 사용하면 된다. (두 가지 방식 중 하나만 지원하는 경우도 있기 때문)
- 대표적으로 대입 연산자 (a = b)는 전역 연산자로 만들 수 없다.
연산자 오버로딩의 제한
- 연산자 오버로딩도 가능한 연산자가 있고, 불가능한 연산자들이 있다.
- 이렇게 정해놓은 이유는 기본적인 C++의 문법이 어긋날 수 있기 때문이다.
오버로딩이 불가능한 연산자들
연산자 기호 | 이름 |
. | 멤버 접근 연산자 |
* | 멤버 포인터 연산 |
:: | 범위 지정 연산자 |
? | 조건 연산자 (삼향 연산자) |
Class 내에서 멤버 함수의 형태로만 구현이 가능한 연산자들
연산자 기호 | 이름 |
= | 대입 연산자 |
( ) | 함수 호출 연산자 |
[ ] | 배열 접근 연산자 (인덱스) |
-> | 멤버 접근을 위한 포인터 연산자 |
class Position
{
public:
// 멤버 연산자 함수 (클래스 + 클래스)
Position operator+(const Position& arg)
{
Position pos;
pos._x = _x + arg._x;
pos._y = _y + arg._y;
return pos;
}
// 멤버 연산자 함수 (클래스 + 상수)
Position operator+(int arg)
{
Position pos;
pos._x = _x + arg;
pos._y = _y + arg;
return pos;
}
public:
int _x;
int _y;
};
// 전역 연산자 함수 (상수 + 클래스)
Position operator+(int a, const Position& b)
{
Position ret;
ret._x = b._x + a;
ret._y = b._y + a;
return ret;
}
int main()
{
Position pos;
pos._x = 0;
pos._y = 0;
Position pos2;
pos2._x = 1;
pos2._y = 1;
Position pos3 = pos + pos2;
Position pos4 = 5 + pos3;
return 0;
}
다양한 연산자 구현
class Position
{
public:
// +
Position operator+(const Position& arg)
{
Position pos;
pos._x = _x + arg._x;
pos._y = _y + arg._y;
return pos;
}
// ==
bool operator==(const Position& arg)
{
return _x == arg._x && _y == arg._y;
}
// >
bool operator>(const Position& arg)
{
if (_x > arg._x && _y > arg._y)
return true;
}
// =
Position& operator=(const Position& arg)
{
_x = arg._x;
_y = arg._y;
// Position* this = 내 자신의 주소
return *this;
}
public:
int _x;
int _y;
};
*this 란? : 내 자신의 주소를 가리키는 포인터이다. (자기 자신의 값, 포인터의 참조)
= 연산자의 구현을 보면 자신의 _x와 _y한테 매개변수의 _x, _y를 대입받고 그 값을 반환할 때, *this를 사용하여 자신의 값을 리턴해 주는 것을 볼 수 있다.