C++은 할건데, C랑 다른 것만 합니다. 3편 소멸자와 복사 생성자, RAII 패턴

0

C++

목록 보기
3/10

소멸자와 복사 생성자, RAII 패턴

https://modoocode.com/188

본 글은 위 글을 정리한 내용입니다.

1. 소멸자

우리는 new 동적 할당을 통해 힙영역에 메모리를 할당할 수 있다는 것을 알고 있다.

그리고 해제할 때는 delete연산자를 통해 해제할 수 있다고 했다.

이는 클래스 역시 마찬가지인데, 다음의 경우를 확인해보자

#include <iostream>
#include <cstring>
using namespace std;

class People{
    private:
        int age;

    public:
        People(int _age);
        ~People();
        void print_info();
        
};

People::People(int _age){
    age = _age;
}

People::~People(){
    cout << "파괴!! " << age << endl;
}

void People::print_info(){
    cout << "People info age is " << age << endl;  
}

int main(){
    People *people[3];

    people[0] = new People(10);
    people[1] = new People(12);
    people[2] = new People(18);

    for(int i = 0; i < 3; i++){
        people[i]->print_info();
    }
    delete people[0];
    delete people[1];
    delete people[2];

    cout << "파괴 이후" << endl;
}
People info age is 10
People info age is 12
People info age is 18
파괴!! 10
파괴!! 12
파괴!! 18
파괴 이후

다음의 코드를 보면 People 클래스를 생성할 때 포인터 배열을 만들고, 이들의 인스턴스를 new 를 통해 동적 할당했다.

People *people[3];
people[0] = new People(10);
people[1] = new People(12);
people[2] = new People(18);

이렇게 new를 통해 클래스를 동적할당 할 때에 생성자를 호출해 줄 수 있는데, 이것이 malloc과 new의 큰 차이점 중에 하나이다.

T* pT = new T(); // 생성자 함수 호출
T *pT = new T[size]; // size*sizeof(T)만큼 공간할당

그리고 메모리를 해제할 때도 delete를 통해 해제해줄 수 있다. new를 통해 생성자를 호출했듯이, 우리는 delete를 통해 소멸자를 호출할 수 있다.

delete people[0];
delete people[1];
delete people[2];

메모리가 해제될 때 호출되는 함수가 있는데, 이것이 바로 소멸자이다. 소멸자의 정의는 다음과 같다.

~T(){

}

동적 할당을 통해 얻은 메모리 주소는 개발자가 메모리를 해제해주지 않으면 자동으로 반환되지 않는다. 메인 프로그램이 죽을 때까지 메모리를 잡고 있으며 , 심지어 소멸자는 메인 프로그램이 종료되고 호출이 안된다.

  • 소멸자를 제거한 후 결과
People info age is 10
People info age is 12
People info age is 18
파괴 이후

그래서, 언제나 heap영역에 할당해준 메모리는 잘 해제해주어야 한다.

그런데 문제는 바로 이런 경우이다.

#include <iostream>
#include <cstring>
using namespace std;

class People{
    private:
        int age;
        char *name;

    public:
        People();
        People(int _age, const char* _name);
        void print_info();
};

People::People(){}
People::People(int _age, const char * _name){
    int len = strlen(_name);
    name = new char[len+1];
    strcpy(name, _name);
    age = _age;
}

void People::print_info(){
    cout << "People info : name is " << name << ", age is " << age << endl;  
}

int main(){
    People people(10, "gyu-young-park");
    people.print_info();
}
People info : name is gyu-young-park, age is 10

무엇이 문제인가 싶을 것이다.

people 인스턴스의 경우에는 지역 변수이고, 힙이 아닌 main 함수 호출 스택에 있다. 따라서, main 함수가 끝나면 메모리도 반환하게 된다.

그러나, 진짜 문제는 people안에 있는 name이다.

name의 경우 동적할당 되었으므로, people이 사라져도 name 변수가 할당한 메모리 영역은 남아있게 된다. 따라서, 이런 것들이 쌓이게 되고 프로그램은 계속해서 메모리 누수가 발생하게 된다.

즉, people이 종료되면, name도 동적할당된 메모리를 제거해주어야 하는 것이다.

이를 위해 다음과 같이 소멸자를 이용하여 동적할당된 name 메모리를 제거해주도록 코드를 변경해보자

#include <iostream>
#include <cstring>
using namespace std;

class People{
    private:
        int age;
        char *name;

    public:
        People();
        ~People();
        People(int _age, const char* _name);
        void print_info();
};

People::People(){}
People::~People(){
    cout << "people 소멸자 호출 name 제거" << endl;
    delete[] name;
}
People::People(int _age, const char* _name){
    int len = strlen(_name);
    name = new char[len+1];
    strcpy(name, _name);
    age = _age;
}

void People::print_info(){
    cout << "People info : name is " << name << ", age is " << age << endl;  
}

int main(){
    People people(10, "gyu-young-park");
    people.print_info();
}
People info : name is gyu-young-park, age is 10
people 소멸자 호출 name 제거

위는 main에서 people 인스턴스가 지역 변수로서 호출 스택에서 제거될 때 소멸자가 불리면 name을 삭제해주도록 하였다.

People::~People(){
    cout << "people 소멸자 호출 name 제거" << endl;
    delete[] name;
}

매우 간단하지만, 메모리 누수 문제는 시스템에 굉장히 치명적이고 디버깅하기 어려운 버그 중에 하나이다. 따라서, 이런 문제를 방지하기 위한 패턴이 만들어지게 되었고 이것을 RAII이라고 부른다.

여담이지만, 발음을 어떻게할지 몰라서, 그냥 대문자 R.A.I.I라고 발음한다.

RAII(Resource Acquisition is Initialization) 패턴

https://stackoverflow.com/questions/2321511/what-is-meant-by-resource-acquisition-is-initialization-raii

위 글을 참조하였다. 이름 자체가 굉장히 이상해서 의미론적인 해석이 많이 어렵다.

그래서 RAII이라는 이름 대신에 Scope-Bound Resource Management라는 이름을 붙였지만, RAII이 더 유명하다.

Scope-Bound라는 의미는 스코프에 한정되어있다는 의미이다. 무엇이 말인가? 자원이다. 자원은 메모리를 말하는 것이 아니라, 메모리를 자지하고 있는 객체들을 말한다.

가령, 소켓이나, 파일 핸들러나, 위에서는 name[]이 되겠다.

이런 자원들이 어느 한 스코프에 한정되어 관리된다는 것이다.

여기서 한정되었다는 의미는, 해당 스코프에 의존적이라는 의미이다. 즉, 해당 스코프가 종료되면 자원도 메모리를 해제하여 삭제된다는 것이다.

그렇다면 어떻게 스코프에 자원을 한정하고, 삭제할 것인가??

스코프는 하나의 클래스 내부가 되며, 자원은 클래스 안의 객체가 된다. 그리고, 삭제는 소멸자를 이용한 동적 할당 해제가 될 것이다.

이것이 RAII 패턴이다.

왜 이런 RAII 패턴을 적용해야할까??

다음의 예제를 보자,

MySocketHandler *pSocketHandler = new MySocketHandler(port, ip);
pSocketHandler->connect(); // throw exception

...

delete pSocketHandler;

위의 코드에서 pSocketHandler에서 예외를 던졌다고 하자, 그래서 프로그램이 다운되고, 재구동되낟면 pSocketHandler의 메모리 자원은 반환이 될까??

안된다. 왜냐하면 delete pSocketHandler에 닿지 못하기 때문이다.

그래서 RAII 패턴을 이용하여 이를 해결하면 다음과 같다.

class ManageMySocketHandler {
    public:
        ManageMySocketHandler(MySocketHandler *_pSocketHandler) : pSocketHandler(_pSocketHandler) {};
        ~ManageMySocketHandler(){delete pSocketHandler}
        void connect() {
            pSocketHandler->connect(); 
        }
        ...
    private:
        MySocketHandler* pSocketHandler;
}
ManageMySocketHandler manageMySocketHandler(new MySocketHandler(port, ip));

manageMySocketHandler->connect(); 

ManageMySocketHandler 클래스를 만들고, 생성자로 MySocketHandler을 받는다. 그리고, ManageMySocketHandler을 통해 connect 메서드를 호출하면 MySocketHandlerconnect 메서드를 호출하게 된다.

만약, 여기서 연결에 실패하여 시스템이 다운된다고 하자, 그럼 호출 스택에서 지역 변수인 manageMySocketHandler인스턴스를 제거하고, 소멸자를 호출한다.

그러면 소멸자 연산으로 delete pSocketHandler을 수행하게되고 pSocketHandler의 동적할당 메모리가 해제된다.

이렇게 자원을 관리하여, 원치 않은 에러 상황에서도 메모리 누수를 막는 패턴이 RAII 패턴이다.

추가적으로 소멸자는 default 소멸자가 있다. 물론 이 내부에서는 아무런 작업도 하지 않지만, 소멸자가 필요없는 클래스라면 굳이 소멸자를 따로 써줄 필요가 없다.

2. 복사 생성자

생성자이기 때문에, 생성시에만 작동하는 문법이다.

복사 생성자는 다음과 같이 어떤 인스턴스의 값을 다른 인스턴스가 생성될 때 복사해주는 것을 말한다.

Socket sock2 = sock1; 

마치 대입 연산자마냥 보이는데, 사실은 다음과 같다.

Socket sock2(sock1); 

이것이 복사 연산자이다. 일반형으로 쓰면 다음과 같다.

T instance = instance2;

기본적으로 default 복사 생성자가 있다. 따라서 다음의 경우가 성립한다.

#include <iostream>
#include <cstring>
using namespace std;

class Order{
    private:
        int id;
        int price;
        int count;
    public:
        Order();
        Order(int _id, int _price, int _count);
        void print_info();
};

Order::Order(){};
Order::Order(int _id, int _price, int _count){
    id = _id;
    price = _price;
    count = _count;
}
void Order::print_info(){
    cout << "Print Order Info : " << id << " " << price << " " << count << endl;
}

int main(){
    Order order1(1,2,3);
    Order order2 = order1;
    order1.print_info();
    cout << "-------------복사------------" << endl;
    order2.print_info();
}
Print Order Info : 1 2 3
-------------복사------------
Print Order Info : 1 2 3

기본 복사 생성자가 이미 있기 때문에, Order2나 Order1이나 같은 값을 출력하게 된다.

그런데, 사실 ID는 고유의 값이므로 복사하고 싶지않다. 이렇게 특징 값은 다르게 해야하거나, 다른 타이밍에 넣어줘야 할 때는 복사 생성자를 직접 만들어주어 넣어주면 된다.

복사 생성자를 만들때는 const 참조자를 사용한다. 왜 const 참조자라면, 이는 참조자 내부의 값을 바꾸지 않을 뿐더라, 무겁게 class자체를 매개변수에 복사하여 가져오는게 아니라 별칭으로 가져와 가볍기 때문이다.

Order(const Order& order);

다음과 같이 정의해야한다.

일반형으로 쓰면 다음과 같다.

T(const T& a);

위의 예제에서 id만 증가시키는 복사 생성자를 만들어보자

#include <iostream>
#include <cstring>
using namespace std;

class Order{
    private:
        int id;
        int price;
        int count;
    public:
        Order();
        Order(int _id, int _price, int _count);
        Order(const Order& order);
        void print_info();
};

Order::Order(){};
Order::Order(int _id, int _price, int _count){
    id = _id;
    price = _price;
    count = _count;
}
Order::Order(const Order& order){
    id = order.id + 1;
    price = order.price;
    count = order.count;
}
void Order::print_info(){
    cout << "Print Order Info : " << id << " " << price << " " << count << endl;
}

int main(){
    Order order1(1,2,3);
    Order order2 = order1;
    order1.print_info();
    cout << "-------------복사------------" << endl;
    order2.print_info();
}
Print Order Info : 1 2 3
-------------복사------------
Print Order Info : 2 2 3

다음과 같이 복사 생성자에 의해 값이 복사되어, 기존 id보다 하나 증가한 id가 복사되게 된다.

복사 생성자의 한계

그러나, 복사 생성자는 얕은 복사를 실행한다.

얕은 복사와 깊은 복사가 있는데, 값 같은 경우는 상관없이 그대로 값을 복사한다. 그런데 구조체나 인스턴스의 경우에는 달라진다.

이들은 값자체로 대입시켜줄 수 없기 때문에, 어떻게 복사할 지를 결정해야 한다.

얕은 복사는 마치 주소를 넘겨주는 것이다. 즉 새로운 메모리 주소를 만들어서 값들을 복사하는 것이 아니라, 해당 구조체, 인스턴스가 그대로 넘어가는 것이다.

반면 깊은 복사는 새로운 메모리 주소를 만들어 거기에 복사하려는 대상의 값들을 복사하는 식이다.

#include <iostream>
#include <cstring>
using namespace std;

class Product{
    private:
        int id;
        int price;
    public:
        Product(int _id, int _price){
            id = _id;
            price = _price;
        }
        void print_product(){
            cout << "Product info " << id << " " << price << endl;
        }
};

class Order{
    private:
        int id;
        int count;
        Product* product;
    public:
        Order();
        Order(int _id, int _count, Product *_product);
        ~Order();
        void print_info();
};

Order::Order(){};
Order::Order(int _id, int _count, Product *_product){
    id = _id;
    count = _count;
    product = _product;
}
Order::~Order(){
    if(product == NULL) return;
    delete product;
}
void Order::print_info(){
    cout << "Print Order Info : " << id <<" " << count << endl;
    product->print_product();
}

int main(){
    Product *product = new Product(10,20);
    Order order1(1,2,product);

    order1.print_info();
    cout << "after..." << endl;
    Order order2 = order1;
    order2.print_info();
}
Print Order Info : 1 2
Product info 10 20
after...
Print Order Info : 1 2
Product info 10 20

id, count같은 값들은 그냥 대입연산자로 복사된다고 보면 된다. 그런데 product는 포인터이기 때문에 얕은 복사가 이루어진다.

언뜻보면 문제가 없어보인다. 그런데 여기서 만약 order1이나 order2에서 product를 없애면 어떻게 될까??


void Order::delete_product(){
    delete product;
    product = NULL;
}

int main(){
    Product *product = new Product(10,20);
    Order order1(1,2,product);
    order1.print_info();
    cout << "after..." << endl;
    Order order2 = order1;

    order1.delete_product();

    order2.print_info();
}

다음과 같이 delete_product()메서드를 추가하여 order1의 product를 삭제한다고 하자, order1이 삭제를 호출하였는데, order2는 다음과 같이 runtime error와 쓰레기 값이 나온다.

Runtime error 
Print Order Info : 1 2
Product info 10 20
after...
Print Order Info : 1 2
Product info 0 0

왜 이런 현상이 벌어질까??

얕은 복사의 경우, 주소만 넘겨주는 방식이기 때문이다. 즉, order1의 product가 가리키는 주소나, order2의 product가 가리키는 주소가 같다.

이 경우 order1나 order2의 product를 해제하면, 둘의 product 포인터는 없는 메모리 주소를 참조하는 댕글링 포인터가 된다.

그래서 이와 같은 경우는 따로 복사 생성자를 만들어 깊은 복사가 이루어지도록 해야한다.

최종적으로 다음과 같은 코드가 되어야 한다.

#include <iostream>
#include <cstring>
using namespace std;

class Product{
    private:
        int id;
        int price;
    public:
        Product(int _id, int _price){
            id = _id;
            price = _price;
        }
        Product(const Product& _product){
            id = _product.id;
            price = _product.price;
        }
        void print_product(){
            cout << "Product info " << id << " " << price << endl;
        }
};

class Order{
    public:
        int id;
        int count;
        Product* product;
        Order();
        Order(int _id, int _count, Product *_product);
        Order(const Order& _order);
        ~Order();
        void delete_product();
        void print_info();
};

Order::Order(){};
Order::Order(int _id, int _count, Product *_product){
    id = _id;
    count = _count;
    product = _product;
}
Order::~Order(){
    if(product == NULL) return;
    delete product;
}
Order::Order(const Order& _order){
    id = _order.id;
    count = _order.count;
    product = new Product(*_order.product);
}
void Order::delete_product(){
    delete product;
    product = NULL;
}

void Order::print_info(){
    cout << "Print Order Info : " << id <<" " << count << endl;
    product->print_product();
}

int main(){
    Product *product = new Product(10,20);
    Order order1(1,2,product);
    order1.print_info();
    cout << "after..." << endl;
    Order order2 = order1;
    order1.delete_product();
    order2.print_info();
}
Print Order Info : 1 2
Product info 10 20
after...
Print Order Info : 1 2
Product info 10 20

0개의 댓글