프로그래밍 공부
작성일
2024. 2. 5. 16:59
작성자
WDmil
728x90

8. 클래스와 객체 마스터하기


8.1 동적 메모리 할당을 통한 객체 생성

 

메모리가 얼마나 필요할지 프로그램을 실행해보기 전에는 알 수 없는 떄도 있다. 이미 알고 있듯이 이럴 떄에 대한 해답은 동적 메모리 할당이다. 클래스 또한 예외가 아니다. 클래스를 정의 할 때 멤버 객체가 메모리를 얼마나 소요할지 알 수 없는 경우가 있다. 이때 그 객체는 동적으로 메모리를 할당받아야 한다. 동적으로 할당된 객체는 메모리 해제, 객체 복제, 객체 대입 연산 등과 관련해서 조금 까다로운 부분이 있다.


8.1.1 SpreadSheet 클래스

7장에서 만들었던 클래스를 예제로 활용한다. SpreadSheetCell이 그랬듯이 SpreadSheet클래스도 이 장에서 점진적으로 강화해 나갈 것 이다. 각 버전의 SpreadSheet클래스는 예시적인 용도이기 때문에 모범적이지 않을 수는 있다.

 

SpreadSheet는 SpreadSheetCell의 2차원 배열을 데이터 멤버로 가지며 특정 위치의 셀을 읽고 쓸 수 있는 메서드를 제공한다. 대부분의 상용 스프레드시트 프로그램이 가로축과 세로축에 각각 알파벳과 숫자를 사용하지만, 이 예제에서는 두 축 모두 숫자만 사용한다.

#pragma once
class SpreadSheetCell
{
public:
	SpreadSheetCell(int iWidth, int inHeight);
	void setCellAt(int x, int y, const SpreadSheetCell& cell);
	SpreadSheetCell& getCellAt(int x, int y);
private:
	bool inRange(int val, int upper);
	int mWidth, mHeight;
	SpreadSheetCell** mCells;
};

 

이 모드에서는 mCell배열에 보인터를 사용하고 있다. 포인터 이용에 따른 문제와 동적 메모리 할당에대한 문제는 조금 있다가 설명한다. 실질 코드는 포인터 대신 1장에서 소개한 vector와 같은 표준 C++컨테이너 클래스를 이용해야 한다. 현대 C++프로그래밍 에서는 절대로 일반 포인터를 사용해서는 안된다. 하지만 일반 포인터를 사용한 기존 코드를 대상으로 작업을 하려면 일반 포인터가 어떻게 작동하고 어떻게 다루어야 하는지 알아야 하기 때문에 여기서는 일반 포인터를 사용한다.

 

SpreadSheet 클래스는 SpreadSheetCell의 2차원 배열을 가지는 대신 SpreadSheetCell** 타입의 멤버를 가지고 있다. 그 이유는 각 SpreadSheet 객체가 서로 다른 크기의 셀 격자를 가질 수 있기 때문이다. 포인터 타입을 통해 생성자에서 셀 격자의 높이와 너비에 맞추어 동적으로 메모리를 할당할 수 있다.

 

2차원 배열의 동적 할당 코드는 다음과 같이 작성할 수 있다.

SpreadSheet::SpreadSheet(int inWidth, int inHeight) :
    mWidth(inWidth), mHeight(inHeight)
{
    mCells = new SpreadSheetCell * [mWidth];
    for (int i = 0; i < mWidth; i++)
        mCells[i] = new SpreadSheetCell[mHeight];
}

높이 3, 너비 4 크기의 셀 격자를 갖게 SpreadSheet 객체를 스택에 생성하면 다음과 같은 메모리 구조를 가진다.

 

각 셀을 읽고 쓰기 위한 메서드는 다음과 같다.

void SpreadSheet::setCellAt(int x, int y, const SpreadSheetCell& cell)
{
    if (!inRange(x, mWidth) || !inRange(y, mHeight)) {
        throw std::out_of_range("");
    }
    mCells[x][y] = cell;
}

위 코드에서는 x, y좌표가 유효한 셀 좌표를 가리키는지 검사하기 위해 inRange()라는 메서드를 이용하고 있다. 범위를 벗어난 좌표에 접근하게 되면 프로그램이 이상 동작을 일으킬 수 있다. 그러한 상황을 피하기 위해 익셉션을 이용하고 있다.

 


8.1.2 소멸자를 이용한 메모리 해제

 

동적으로 할당한 메모리는 사용이 끝난 후 반드시 해제해주어야 한다. 만약 객체 안에서 동적으로 메모리를 할당했다면 그 메모리에 대한 해제는 객체 소멸자 안에 수행되는 것이 가장 바람직하다. 컴파일러는 객체가 삭제될 때 소멸자가 호출되도록 보증해준다.

class SpreadSheet
{
public:
	SpreadSheet(int inWidth, int inHeight);
	~SpreadSheet();

소멸자는 클래스의 이름과 같은 이름을 가지되 ~ 기호를 앞에 붙인다. 소멸자는 파라미터를 가질 수 없으며 생성자와 달리 한 종류만 존재한다.

SpreadSheet::~SpreadSheet()
{
    for (int i = 0; i < mWidth; i++)
        delete[] mCells[i];

    delete[] mCells;
    mCells = nullptr;
}

 

이 소멸자는 생성자에서 할당한 메모리를 해제하고 있다. 소멸자에서 메모리에 해제 코드만있어야 하는 것 은 아니다.

프로그래머가 원한다면 어떠한 코드든 넣을 수 있다. 하지만 소멸자에서는 그 의미에 맞게 메모리 해제와 같이 사용한 리소스의 반환 작업을 수행하는 것이 바람직하다.

 


8.1.3 복제와 대입의 관리

복제 생성자와 대입 연산자를 직접 만들지 않으면 컴파일러가 자동으로 생성해준다. 컴파일러가 자동으로 생성한 메서드는 데이터 멤버에 대해 제귀적으로 복제 생성자 또는 대입 연산자를 호출한다. 단, int, double, 포인터와 같은 기본 데이터 타입에 대해서는 복제 생성자나 대입 연산자 대신 얕은 복제(비트단위 복제 라고도 한다. 포인터가 가리키는 데이터는 뺴놓고 피상적으로 그 변숫값, 즉 주솟값만 복제하는것) 가 일어난다. 즉, 특별한 함수 호출 없이 데이터 멤버와 비트 값을 원본에서 대상으로 복제하기만 한다.

 

이러한 얕은 복제는 객체가 동적으로 할당받은 메모리를 가지고 있을 때 문제가 된다. 예를 들어 printSpreadSheet() 함수를 호출할 때, 함수 파라미터 s는 s1으로 초기화되어 새로 생성된다.

 

void printSpreadSheet(SpreadSheet s) {};
int main()
{
	SpreadSheet s1(4, 3);
	printSpreadSheet(s1);
	return 0;
}

 

SpreadSheet는 포인터 변수 mCells를 가지고 있다. 얕은 복제는 mCells의 포인터만 원본에서 대상으로 복제하고 그 데이터는 복제하지 않는다. 이 때문에 s와 s1 둘 다 같은 mCells데이터를 가리키게 된다.

 

만약 s가 mCells의 데이터에 변경을 가하면 s1에도 바로 반영된다. 더 최악의 상황으로 함수 printSpreadSheet()가 리턴하면 스택 객체인 s의 소멸자가 호출되면서 다음과 같이 mCells 포인터가 가리키는 메모리를 해제해버린다.

이러면 s1이 가리키는 포인터는 더 이상 유효하지 않다. 이러한 포인터를 댕글링 포인터(dangling pointer) 라고 한다.

 

그런데 대입을 할 떄는 더 최악이 왼다. 다음 코드를 살펴보자.

SpreadSheet s1(2,2), s2(4, 3);
s1 = s2;

s1, s2 두 객체가 생성된 직후 메모리 상태는 다음과 같이 된다.

대입이 실행되고 나서는 다음 상태가 된다.

s1과 s2의 mCells 포인터가 같은 메모리를 가리키는 것은 물론 s1의 mCells 포인터가 가리키고 있던 메모리 영역이 주인을 잃어버린다. 이러한 문제를 피하기 위해서는 대입 대상인 좌변항의 객체가 참조하고 있는 메모리를 반환한 후, 새로 메모리를 준비하여 깊은 복제(포인터 변숫값만 파상적으로 복제하지 않고 그 변수의 맥락에 맞게 연관된 데이터까지 재귀적으로 온전하게 복제하는 것) 을 해야한다.

 

이러한 문제들 때문에 컴파일러가 자동으로 생성하는 복제 생성자와 대입 연산자를 그대로 이용하는 것은 매우 위험하다.

 

클래스에서 메모리를 동적으로 할당하는 경우가 있다면 항상 복제 생성자와 대입 연산자를 프로그래머가 직접 구현하여 깊은 복제가 일어나도록 해야한다.


8.1.3.1 SpreadSheet의 복제 생성자

다음은 복제 생성자가 포함된 SpreadSheet 클래스 정의 이다.

class SpreadSheet
{
public:
	SpreadSheet(int inWidth, int inHeight);
	SpreadSheet(const SpreadSheet& src);

다음은 SpreadSheet 클래스의 복제 생성자 구현 예 이다.

SpreadSheet::SpreadSheet(const SpreadSheet& src)
{
    mWidth = src.mWidth;
    mHeight = src.mHeight;
    mCells = new SpreadSheetCell * [mWidth];
    for (int i = 0; i < mWidth; i++)
        mCells[i] = new SpreadSheetCell[mHeight];

    for (int i = 0; i < mWidth; i++)
        for (int j = 0; j < mHeight; j++)
            mCells[i][j] = src.mCells[i][j];
}

당연하지만, 이 복제 생성자에서는 포인터 데이터뿐만 아니라 mWidth나 mHeight같은 다른 데이터 멤버들도 모두 복제하고 있다. 포인터 데이터 멤버 변수 mCells에 대해서는 깊은 복제를 하기 위해 2차원 배열의 포인터를 순회하며 각 배열 항목을 하나씩 모두 복제한다. 여기서는 복제하기 전에 mCells에 대한 메모리 해제를 할 필요가 없다.

 

객체 생성을 위한 복제 생성자이기 때문에 아직 객체 자체가 존제하지 않는다. 즉, 이 코드가 실행되는 시점에서는 mCells가 할당된적 이 없다.


8.1.3.2 SpreadSheet 대입 연산자.

다음은 대입 연산자가 포함된 SpreadSheet 클래스 정의다.

class SpreadSheet
{
public:
	SpreadSheet(int inWidth, int inHeight);
	SpreadSheet(const SpreadSheet& src);
	~SpreadSheet();
	void setCellAt(int x, int y, const SpreadSheetCell& cell);
	SpreadSheetCell& getCellAt(int x, int y);

public:
	SpreadSheet& operator=(const SpreadSheet& rhs);

대입 연산자의 구현이다.

어떤 객체가 대입 대상으로 원본을 객체로 받아들일 때는 이미 객체가 생성되어 있고 값도 설정된 상태다. 이 때문에 새로운 값을 대입하기 전에 기존 데이터 멤버가 동적으로 할당받은 메모리에 대해 해제 작업을 수행한 후 새로운 메모리를 할당해야 한다. 대입 연산자가 하는 일은 소멸자와 복제 생성자가 연달아 호출되는 것과 비슷하다. 즉, 대입 연산자는 대입 받는 객체를 아주 새로운 데이터로 부활시키는 셈이다.

 

대입 연산자에서 가장 먼저 할 일은 다음처럼 자기 자신을 대입하고 있는지 확인하여 무시하는 일이다.

SpreadSheet& SpreadSheet::operator=(const SpreadSheet& rhs)
{
    // 자기 자신을 대입하는지 검사
    if (this == &rhs)
        return *this;


}

이런 작업을 하는 이유는, 성능 최적화 뿐만 아니라 오류를 방지하기 위한 것도 있다. 만약 자기 자신인지 확인하는 코드가 없다면, 자기 자신을 대입하는 코드가 실행될 때 프로그램이 잘못된 메모리 접근으로 비정상 종료될 수도 있다.

 

왜냐하면 대입 대상 객체에서는 보유하고 있던 동적 메모리를 대입하기 전에 먼저 해제하는데, 대상과 원본이 같으므로 이 순간에 원본 객체의 메모리도 해제되어 포인터 멤버가 댕글링 포인터가 되어버리기 때문이다. 그래서 복제 작업시 댕글링 포인터를 참조하게 되고 어떤 문제가 발생할지 알 수 없다.

 

자기 대입을 확인해보고 아닐 때 기존에 가지고 있던 mCells 데이터들을 해제한다.

SpreadSheet& SpreadSheet::operator=(const SpreadSheet& rhs)
{
    // 자기 자신을 대입하는지 검사
    if (this == &rhs)
        return *this;
    for (int i = 0; i < mWidth; i++) {
        delete[] mCells[i];
    }
    delete[]mCells;
    mCells = nullptr;
}

위 코드는 소멸자 코드에 있는 것과 같다. 이렇게 데이터를 해제하지 않고 새로운 메모리를 할당하면 메모리 릭이 발생한다.

SpreadSheet& SpreadSheet::operator=(const SpreadSheet& rhs)
{
    // 자기 자신을 대입하는지 검사
    if (this == &rhs)
        return *this;
    for (int i = 0; i < mWidth; i++) {
        delete[] mCells[i];
    }
    delete[]mCells;
    mCells = nullptr;

    // 새로운 메모리를 할당하고 값을 복제
    mWidth = rhs.mWidth;
    mHeight = rhs.mHeight;
    mCells = new SpreadSheetCell * [mWidth];
    for (int i = 0; i < mWidth; i++)
        for (int j = 0; j < mHeight; j++)
            mCells[i][j] = rhs.mCells[i][j];

    return *this;
}

위 코드는 복제 생성자의 코드와 거의 같다.

 

대입 연산자를 구현하면, 사실상 소멸자와 복제 생성자를 함께 구현하는 셈이 된다. 그러므로 소멸자, 복제 생성자, 대입 연산자는 서로 연관된 코드를 가질 수 밖에 없다. 이 떄문에 세 매서드 중 하나라도 구현해야 한다면 세 메서드를 모두 구현하여 관리해야 한다.

 

클래스가 동적 할당 메모리를 가진다면, 소멸자 복제 생성자 대입 연산자 를 꼭 구현해야 한다!.

 

C++는 이동 시맨틱을 지원한다. 이 때문에 이동 생성자와 이동 대입 연산자가 필요하다. 이러한 메서드들은 특정 상황에서 성능 오버헤드를 줄이는 데 사용할 수 있다.

 


8.1.3.3 복제 생성자와 대입 연산자를 위한 공용 편의 루틴

복제 생성자와 대입 연산자는 상당히 유사하다. 이 때문에 공통적인 작업 부분을 편의 메서드로 빼두면 중복 코드를 피할 수 있다.

 

예를 들어 SpreadSheet 클래스에 CopyFrom() 메서드를 추가하면 복제 생성자와 대입 연산자의 구현을 다음처럼 단순화 할 수 있다.

SpreadSheet::SpreadSheet(int inWidth, int inHeight) :
    mWidth(inWidth), mHeight(inHeight)
{
    mCells = new SpreadSheetCell * [mWidth];
    for (int i = 0; i < mWidth; i++)
        mCells[i] = new SpreadSheetCell[mHeight];
}

SpreadSheet::SpreadSheet(const SpreadSheet& src)
{
    copyFrom(src);
}

SpreadSheet::~SpreadSheet()
{
    for (int i = 0; i < mWidth; i++)
        delete[] mCells[i];

    delete[] mCells;
    mCells = nullptr;
}

void SpreadSheet::setCellAt(int x, int y, const SpreadSheetCell& cell)
{
    if (!inRange(x, mWidth) || !inRange(y, mHeight)) {
        throw std::out_of_range("");
    }
    mCells[x][y] = cell;
}

SpreadSheetCell& SpreadSheet::getCellAt(int x, int y)
{
    // TODO: 여기에 return 문을 삽입합니다.
}

SpreadSheet& SpreadSheet::operator=(const SpreadSheet& rhs)
{
    // 자기 자신을 대입하는지 검사
    if (this == &rhs)
        return *this;
    for (int i = 0; i < mWidth; i++) {
        delete[] mCells[i];
    }
    delete[]mCells;
    mCells = nullptr;

    // 새로운 메모리를 할당하고 값을 복제
    copyFrom(rhs);
    return *this;
}

8.1.3.4 대입 연산과 값에 의한 전달 배제

클래스에서 동적 메모리 할당을 사용할 때 복제와 대입 때문에 발생하는 문제를 피하는 가장 쉬운 방법은 복제와 대입이 발생하지 않도록 막는 것이다.  operator=과 복제 생성자를 명시적으로 삭제하면 쉽게 막을 수 있다. 이때 객체를 함수에서 리턴하거나 인자로 사용하면서 값으로 전달하려 하면 컴파일러에서 에러를 발생시킨다. 다음은 대입 연산과 값에 의한 전달을 배제할 수 있도록 SpreadSheet클래스를 정의한 예다.

class SpreadSheet
{
public:
	SpreadSheet(int inWidth, int inHeight);
	SpreadSheet(const SpreadSheet& src) = delete;
	~SpreadSheet();
	void setCellAt(int x, int y, const SpreadSheetCell& cell);
	SpreadSheetCell& getCellAt(int x, int y);

public:
	SpreadSheet& operator=(const SpreadSheet& rhs) = delete;

이렇게 명시적으로 삭제 선언한 복제 생성자와 대입 연산자는 구현할 필요가 없다.

 

컴파일러에서 이 메서드의 사용을 허락하지 않기 때문에 링커에서 찾지 않는다. 만약 SpreadSheet 객체를 복제하거나 대입하려 하면 컴파일러가 다음과 같은 에러 메세지를 출력한다.

'SpreadSheet &SpreadSheet::operator =(const SpreadSheet &)' : attempting to refernece a deleted Function

만약 컴파일러가 명시적인 멤버 함수 삭제 기능을 지원하지 않는다면, 복제 생성자와 대입 연산자를 private으로 선언하여 사용을 방지할 수 있다. 이때 구현부는 만들지 않아도 된다.

728x90