TL;DR
이 글은 씹어먹는 C++ 4-3을 정리, 요약한 글입니다.
마린 클래스 예제
스타크래프트의 마린을 코드에서 구현해보자
#include <iostream>
class Marine{
int hp;
int coord_x, coord_y;
int damage;
bool is_dead;
public:
Marine();
Marine(int x, int y);
int attack();
void be_attacked(int damage_earn);
void move(int x, int y);
void show_status();
};
Mariine::Marine(){
hp=50;
coord_x=coord_y=0;
damage=5;
is_dead=false;
}
Marine::Marine(int x, int y){
coord_x=x;
coord_y=y;
hp=50;
damage=5;
is_dead=false;
}
void Marine::move(int x, int y){
coord_x=x;
coord_y=y;
}
int Marine::attack(){return damage;}
void Marine::be_attack(int damage_earn){
hp-=damage_earn;
if(hp<=0) is_dead=true;
}
void Marine::show_status(){
std::cout << "** Marine **" << std::endl;
std::cout << "Location: " << coord_x << ", " << coord_y << std::endl;
std::cout << "HP: " << hp << std::endl;
}
int main(){
Marine marine1(2,3);
Marine marine2(3,5);
marine1.show_status();
marine2.show_status();
std::cout << "Marine 1 attacked Marine 2" << std::endl;
marine2.be_attacked(marine1.attack());
marine1.show_status();
marine2.show_status();
}
보통 어떠한 객체의 내부적 성질, 상태 등에 관련된 변수는 모두 private 범주에 두고 객체가 외부에 하는 행동들은 함수로 구현해 public에 두면 된다고 했다. 마린의 상태(hp, location, damage) 등은 private에서 관리하고 마린이 하는 행동(move, attack, be_attacked)은 public 범주에 두었다.
마린을 여러개 만들고 싶을 때는 마린 배열을 만들어 관리하는 방법이 있다.
int main(){
Marine* marines[100];
marines[0]=new Marine(2,3);
marines[1]=new Mariine(3,5);
marines[0].show_status();
marines[1].show_status();
std::cout << Marine 1 attacked Marine 2 << std::endl;
marines[0]->be_attacked(marines[1]->attack());
marines[0]->show_status();
marines[1]->show_status();
delete marines[0];
delete marines[1];
}
new는 c에서 malloc에 대응되고 delete는 c에서의 free에 대응된다. new가 malloc과 다른 점은 객체를 동적으로 생성하면서 생성자도 자동으로 호출해준다는 점이다. 또한 Marine들의 포인터를 가리키는 배열이기 때문에 . 이 아닌 ->를 사용해줘야 한다.
소멸자
Marine 클래스에 이름을 저장할 수 있는 변수를 추가해보자
#include <iostream>
#include <string.h>
class Marine{
int hp;
int coord_x, coord_y;
int damage;
bool is_dead;
char* name;
public:
Marine();
Marine(int x, iint y, const char* marine_name);
Marine(int x, int y);
int attack();
void be_attacked(int damage_earn);
void move(int x, int y);
void show_status();
};
Marine::Marine(){
hp=50;
coord_x=coord_y=0;
damage=5;
is_dead=false;
name=NULL;
}
Marine::Marine(int x, int y, const char* marine_name){
name=new char[strlen(marine_name)+1];
strcpy(name, marine_name);
coord_x=x;
coord_y=y;
hp=50;
damage=5;
is_dead=false;
}
Marine::Marine(int x, int y){
coord_x=x;
coord_y=y;
hp=50;
damage=5;
is_dead=false;
name=NULL;
}
void Marine::move(int x, int y){
coord_x=x;
coord_y=y;
}
int Marine::attack(){return damage;}
void Marine::be_attacked(int damage_earn){
hp-=damage_earn;
if(hp<=0)is_dead=true;
}
void Marine::show_status(){
std::cout << "*** Marine: " << name << "***" << std::endl;
std::cout << "Location: " << coord_x << ", " << coord_y << std::endl;
std::cout << "HP: " << hp << std::endl;
}
int main(){
Marine* marines[100];
marines[0]=new Marine(2,3, "Marine 2");
marines[1]=new Marine(1,5, "Marine 1");
marines[0]->show_status();
marines[1]->show_status();
std::cout << "Marine 1 attacked Marine 2" << std::endl;
marines[0]->be_attacked(marines[1]->attack());
marines[0]->show_status();
marines[1]->show_status();
delete marines[0];
delete marines[1];
}
위 코드에서 name에 마린의 이름을 넣어줄 때 name을 동적으로 생성하여 문자열을 복사하였다. 이렇게 동적으로 할당된 char 배열에 대한 delete는 언제 이루어지는걸까?
우리가 명확히 delete를 하지 않는 한 name은 자동으로 delete되지 않는다.
만약 main 함수 끝에서 marine을 delete할 때, 즉 우리가 생성했던 객체가 소멸될 때 자동으로 호출되는 함수가 있어 name을 delete 해준다면 편리하지 않을까?
C++에서는 이것을 소멸자를 통해 지원한다.
#include <string.h>
#include <iostream>
class Marine {
int hp; // 마린 체력
int coord_x, coord_y; // 마린 위치
int damage; // 공격력
bool is_dead;
char* name; // 마린 이름
public:
Marine(); // 기본 생성자
Marine(int x, int y, const char* marine_name); // 이름까지 지정
Marine(int x, int y); // x, y 좌표에 마린 생성
~Marine();
int attack(); // 데미지를 리턴한다.
void be_attacked(int damage_earn); // 입는 데미지
void move(int x, int y); // 새로운 위치
void show_status(); // 상태를 보여준다.
};
Marine::Marine() {
hp = 50;
coord_x = coord_y = 0;
damage = 5;
is_dead = false;
name = NULL;
}
Marine::Marine(int x, int y, const char* marine_name) {
name = new char[strlen(marine_name) + 1];
strcpy(name, marine_name);
coord_x = x;
coord_y = y;
hp = 50;
damage = 5;
is_dead = false;
}
Marine::Marine(int x, int y) {
coord_x = x;
coord_y = y;
hp = 50;
damage = 5;
is_dead = false;
name = NULL;
}
void Marine::move(int x, int y) {
coord_x = x;
coord_y = y;
}
int Marine::attack() { return damage; }
void Marine::be_attacked(int damage_earn) {
hp -= damage_earn;
if (hp <= 0) is_dead = true;
}
void Marine::show_status() {
std::cout << " *** Marine : " << name << " ***" << std::endl;
std::cout << " Location : ( " << coord_x << " , " << coord_y << " ) "
<< std::endl;
std::cout << " HP : " << hp << std::endl;
}
Marine::~Marine() {
std::cout << name << " 의 소멸자 호출 ! " << std::endl;
if (name != NULL) {
delete[] name;
}
}
int main() {
Marine* marines[100];
marines[0] = new Marine(2, 3, "Marine 2");
marines[1] = new Marine(1, 5, "Marine 1");
marines[0]->show_status();
marines[1]->show_status();
std::cout << std::endl << "마린 1 이 마린 2 를 공격! " << std::endl;
marines[0]->be_attacked(marines[1]->attack());
marines[0]->show_status();
marines[1]->show_status();
delete marines[0];
delete marines[1];
}
소멸자는 ~를 사용해서 만든다. ~(클래스 이름)
소멸자는 인자를 갖지 않고 오버로딩도 안된다. 객체가 소멸할 때 소멸자를 호출한다.
#include <iostream>
class Test{
char c;
public:
Test(char _c){
c=_c;
std::cout << "생성자 호출" << c << std::endl;
}
~Test(){std::cout << "소멸자 호출" << c << std::endl; }
};
void simple_function(){Test b('b');}
int main(){
Test a('a');
simple_function();
}
생성자 호출 a
생성자 호출 b
소멸자 호출 b
소멸자 호출 a
소멸자가 하는 가장 흔한 역할은 객체가 동적으로 할당받은 메모리를 해제하는 일이다. 그 외에도 쓰레드 사이에서 lock 된 것을 푸는 역할 등을 수행한다.
복사 생성자
스타크래프트에서 동일한 여러개의 포토 캐논이 필요할 때가 있다. 각각의 포토 캐논을 일일히 생성자로 생성할 수도 있지만 1개만 생성하고 나머지 포토캐논은 복사 생성할 수도 있다.
#include <string.h>
#include <iostream>
class Photon_Cannon{
int hp, shield;
int coord_x, coord_y;
int damage;
public:
Photon_Cannon(int x, int y);
Photon_Cannon(const Photon_Cannon& pc);
void show_status();
};
Photon_Cannon::Photon_Cannon(const Photon_Cannon& pc){
std::cout << "복사 생성자 호출" << std::endl;
hp=pc.hp;
shield=pc.shield;
coord_x=pc.coord_x;
coord_y=pc.coord_y;
damage=pc.damage;
}
Photon_Cannon::Photon_Cannon(int x, int y){
std::cout << "생성자 호출" << std::endl;
hp=shield=100;
coord_x=x;
coord_y=y;
damage=20;
}
void Photon_Cannon::show_status(){
std::cout << "Photon Cannon" << std::endl;
std::cout << "Location: " << coord_x << ", " << coord_y << std::endl;
std::cout << "HP: " << hp << std::endl;
}
int main(){
Photon_Cannon pc1(3,3);
Photon_Cannon pc2(pc1);
Photon_Cannon pc3=pc2;
pc1.show_status();
pc2.show_status();
}
생성자 호출
복사 생성자 호출
복사 생성자 호출
Photon Cannon
Location: 3, 3
HP: 100
Photon Cannon
Location: 3, 3
HP: 100
복사 생성자는 어떤 클래스 T가 있다면 T(const T& a)로 정의된다.
복사 생성자 내부에서 a의 데이터는 변경할 수 없고 새롭게 초기화 되는 인스턴스 변수들에게 복사만 가능하다.
cf). 인자로 받는 변수의 내용을 함수 내부에서 바꾸지 않는다면 const를 붙여주는 것이 좋다.
pc1은 생성자를 이용해 생성되었고 pc2는 복사 생성자를 통해 생성되었다. 그리고 pc3도 복사 생성자를 호출하는 문법이다.
Photon_Cannon pc3=pc2;와 Photon_Cannon pc3; pc3=pc2;는 다른 문장이다.
전자는 복사 생성자 호출, 후자는 pc3 생성 후 pc3에 pc2를 대입하는 명령이다.
그리고 디폴트 복사 생성자를 이용하면 복사 생성자를 클래스에 명시하지 않아도 복사 생성자 호출 문법을 사용하면 복사 생성자가 호출된다.
디폴트 복사 생성자의 한계
디폴트 복사 생성자는 얕은 복사를 할 때 문제가 생길 수 있다. 같은 메모리를 두개의 객체가 참조하기 때문에 객체가 소멸할 때 메모리를 두번 해제함으로써 오류가 발생하는 것이다. 이를 방지하기 위해 복사 생성자에서 깊은 복사를 따로 정의해줘야 할 때가 있다.
#include <iostream>
#include <string.h>
class Photon_Cannon{
...
char *name;
public:
...
Photon_Cannon(int x, int y, const char *cannon_name);
Photon_Cannon(const Photon_Cannon &pc);
~Photon_Cannon();
...
};
Photon_Cannon::Photon_Cannon(int x, int y, const char *cannon_name){
hp=shield=100;
coord_x=x;
coord_y=y;
damage=20;
name=new char[strlen(cannon_name)+1];
strcpy(name, cannon_name)
}
Photon_Cannon::Photon_Cannon(const Photon_Cannon &pc){
std::cout << "복사 생성자 호출" << std::endl;
hp=pc.hp;
shield=pc.shield;
coord_x=pc.coord_x;
coord_y=pc.coord_y;
damage=pc.damage;
// 해주지 않으면 double-free error
name=new char[strlen(pc.name)+1];
strcpy(name, pc.name);
}
Photon_Cannon::~Photon_Cannon(){
if(name) delete name;
}
위와 같이 메모리를 새로 할당해서 내용을 복사하는 것을 깊은 복사라고 하고 단순히 대입만 해주는 것을 얕은 복사라고 한다. 컴파일러가 새성하는 디폴트 복사 생성자는 얕은 복사만 가능하기 때문에 깊은 복사가 필요할 때에는 사용자가 직접 복사 생성자를 만들어야 한다.
생성자의 초기화 리스트
#include <iostream>
class Marine{
int hp;
int coord_x, coord_y;
int damage;
bool is_dead;
public:
Marine();
Marine(int x, int y);
int attack();
void be_attacked(int damage_earn);
void move(int x, int y);
void show_status();
};
Marine::Marine() : hp(50), coord_x(0), coord_y(0), damage(5), is_dead(false){}
Marine::Marine(int x, int y) : coord_x(x), coord_y(y), hp(50), damage(5), is_dead(false){}
int Marine::attack(){return damage;}
void Marine::be_attacked(int damage_earn){
hp-=damage_earn;
if(hp<=0) is_dead=true;
}
void Marine::show_status(){
std::cout << "*** Marine ***" << std::endl;
std::cout << "Location: " << coord_x << ", " << coord_y << std::endl;
std::cout << "HP: " << hp << std::endl;
}
int main(){
Marine marine1(2,4);
Marine marine2(3,4);
marine1.show_status();
marine2.show_status();
}
이전에 만든 마린 클래스와는 생성자 문법이 달라졌다.
Marine::Marine() : hp(50), coord_x(0), coord_y(0), damage(5), is_dead(false) {} 이 코드는 기존의 생성자가 해주는 것과 동일한 일을 한다.
생성자 이름 뒤에 : hp(50), coord_x(0), coord_y(0), damage(5), is_dead(false) {} 로 오는 것을 초기화 리스트라고 하며 생성자 호출과 동시에 멤버 변수들을 초기화 해준다.
초기화 리스트를 사용하는 이유는 인스턴스를 생성과 동시에 초기화가 가능하기 때문이다.
만약에 클래스 내부에서 상수나 레퍼런스 변수를 사용하고 싶다면 초기화 리스트를 반드시 사용해야 한다.(선언 후 초기화가 아니라 선언과 초기화가 동시에 이루어져야 하기 때문)
class Marine{
...
const int default_damage;
...
};
Marine::Marine() : hp(50), coord_x(0), coord_y(0), defaule_damage(5), is_dead(false){}
...
C++ Rule of Five
C++에는 Rule of Five라는 것이 있다. 다음 다섯개의 특수 멤버 함수 중 하나라도 직접 정의해야 한다면 나머지 넷도 모두 정의해야 한다는 원칙이다.
- 소멸자 ~T()
- 복사 생성자 T(const T&)
- 복사 대입 연산자 T& operator=(const T&)
- 이동 생성자 T(T&&)
- 이동 대입 연산자 T& operator=(T&&)
왜 필요한가
이 다섯개 중 하나를 직접 작성한다는 것은 클래스가 리소스를 직접 소유한다는 신호이다. 그런데 컴파일러가 자동 생성하는 나머지 함수들은 얕은 복사/이동만 하기 때문에 double-free, dangling pointer, 리소스 누수로 연결될 수 있다.
Rule of Zero
실무적으로 권장되는 패턴은 리소스 관리를 std::unique_ptr, std::vector, std::string 같은 RAII 타입에 위임하여 특수 멤버 함수를 정의하지 않는 것이다.
결론적으로 리소스 소유 계층은 최하위에 한 곳만 두고 Rule of Five를 적용하고 그 위의 모든 클래스는 Rule of Zero로 가는 게 표준적인 설계이다.
3,4,5번은 다른 글에서 다둬보겠다.
댓글
Discussion 원문