복제 생성자나 대입 연산자를 직접 정의하지 않으면
컴파일러가 자동으로 만들어준다.
이렇게 컴파일러에서 생성된 메서드는
객체 타입 데이터 멤버에 대해
복제 생성자와 대입 연산자를 재귀적으로 호출한다.
하지만 int, double, 포인터와 같은 기본 타입에 대해서는
비트 단위 복제(=bitwise copy) (또는 얕은 복제 (=shallow copy))나 대입이 적용된다.
+++ Plus +++
bitwise copy, shallow copy 에서는
원본과 복사된 내용이 모두 메모리에 있는
동일한 객체를 참조한다.
+++++++++++
즉, 원본 객체의 데이터 멤버를 대상 객체로
단순히 복제하거나 대입만 한다.
복제 생성자와 대입 연산자 정의해주기
그런데 객체에 동적으로 할당한 메모리가 있으면 문제가 발생한다.
이러한 문제는
s라는 객체에 s1을 얕은 복제를 하게되면,
s1의 포인터는 데이터를 복제하는 것이 아닌
s1의 포인터의 복제본을 받는다.
이 때 s가 포인터를 해제하면 s1이 가리키던 포인터도 해제된다.
s1의 포인터가 댕글링 포인터가 되는 것이다.
s1의 포인터는 더 이상 올바른 메모리를 가리키지 않으므로
댕글링 포인터에 어떠한 값을 건드리게 될지 모르는 일이다.

또한 얕은 복제, 비트 단위 복제는
대입 연산을 수행할 때 심각한 오류를 발생할 여지가 있다.
여기서 s에서 포인터를 재할당하게 되면
s1의 포인터는 미아가 된 메모리가 되고
이는 메모리 누수로 이어진다.
그러므로 복제 생성자와 대입 연산자는 반드시
깊은 복제를 적용해야 한다.
즉, 복사 포인터 데이터 멤버뿐만 아니라
이러한 포인터가 가리키는 실제 데이터를 복사해야 한다.
이처럼 C++ 컴파일러가 자동으로 생성하는
디폴트 복제 생성자나 대입 연산자를
그대로 사용하면 위험할 수도 있다.
클래스에 동적 할당 메모리가 있다면
이를 깊은 복제로 처리하도록
복제 생성자와 대입 연산자를 직접 정의해야 한다.
// C++ 20 이상
// export 키워드는 모듈 인터페이스에서 사용되어,
// 해당 클래스나 함수의 선언을 모듈의 공개 인터페이스에 포함시킨다.
//export class Spreadsheet
class Spreadsheet
{
public:
Spreadsheet& operator=(const Spreadsheet& rhs);
// ...
private:
bool inRange(size_t value, size_t upper) const;
size_t m_width{ 0 };
size_t m_height{ 0 };
SpreadsheetCell** m_cells{ nullptr };
// ...
};
Spreadsheet& Spreadsheet::operator=(const Spreadsheet& rhs)
{
// 자신을 대입하는지 확인한다
if (this == &rhs)
{
return *this;
}
// 기존 메모리를 해제한다
for (size_t i{ 0 }; i < m_width; i++)
{
delete[] m_cells[i];
}
delete m_cells;
m_cells = nullptr;
// 메모리를 새로 할당한다.
m_width = rhs.m_width;
m_height = rhs.m_height;
m_cells = new SpreadsheetCell * [m_width];
for (size_t i{ 0 }; i < m_width; i++) {
m_cells[i] = new SpreadsheetCell[m_height];
}
// 데이터를 복제한다
for (size_t i{ 0 }; i < m_width; i++) {
for (size_t j{ 0 }; j < m_height; j++) {
m_cells[i][j] = rhs.m_cells[i][j];
}
}
return *this;
}
이 코드는 가장 먼저 자기 자신을 대입하는지 검사한 뒤
this 객체에 할당된 현재 메모리를 해제한다.
그러고 나서 메모리를 새로 할당하고,
마지막으로 개별 원소를 복제한다.
이 메서드는 하는 일이 상당히 많은 만큼 문제가 발생할 여지도 많다.
즉, this 객체가 비정상적인 상태가 될 수 있다.
예를 들어 메모리를 정상적으로 해제해서 m_width와 m_height는 제대로 설정되었지만
메모리를 할당하는 루프문에서 익셉션이 발생했다고 하자.
그러면 이 메서드의 나머지 코드를 건너뛰고 리턴해버린다.
이렇게 Spreadsheet 인스턴스가 비정상적인 상태가 된다.
즉, 여기에 있는 m_width와 m_height 데이터 멤버는 일정한 크기를 갖는다고 선언했지만
실제로는 m_cells 데이터 멤버에 필요한 만큼 메모리를 갖고 있지 않게 된다.
이 코드는 익셉션이 안전하지 않게 구현한 것이다.
문제를 바로 잡으려면 완벽히 정상적으로 처리하거나
this 객체를 건드리지 않도록 작성해야 한다.
익셉션이 발생해도 문제가 발생하지 않도록 대입 연산자를 구하려면
복제 후 맞바꾸기 구문을 적용하는 것이 좋다.
이를 위해 Spreadsheet 클래스에 비 멤버 함수인 swap()을 추가한다.
그래야 다양한 표준 라이브러리 알고리즘에서 활용할 수 있기 때문이다.
방금 설명한 방식에 따라 Spreadsheet 클래스에
대입 연산자와 swap() 함수를 추가하면 다음과 같다.
class Spreadsheet
{
public:
Spreadsheet& operator=(const Spreadsheet& rhs);
void swap(Spreadsheet& other) noexcept;
// 나머지 코드 생략
}
복제 후 맞바꾸기 구문을 익셉션에 안전하게 구현하려면
swap() 함수에서 절대로 익셉션을 돈지면 안된다.
따라서 noexcept로 지정한다.
아무런 익셉션도 던지지 않는 함수 앞에넌 noexcept 키워드를 붙인다.
noexcept로 지정한 함수에서 익셉션을 던지면 프로그램이 멈춘다.
그리고 swap() 함수에서 실제로 데이터 멤버를 교체하는 작업은
표준 라이브러리의<utility>에서 제공하는 유틸리티 함수인 std::swap()으로 처리한다.
void Spreadsheet::swap(Spreadsheet& other) noexcept
{
std::swap(m_width, other.m_width);
std::swap(m_height, other.m_height);
std::swap(m_cells, other.m_cells);
}
비 멤버 버전의 swap()함수는 다음과 같이 주어진 인수를
단순히 앞에 나온 버전의 swap()으로 전달하기만 한다.
void Spreadsheet::swap(Spreadsheet& first, Spreadsheet& second) noexcept
{
first.swap(second);
}
이제 익셉션에 안전하게 만든 swap() 함수를 이용하여 다음과 같이 대입 연산자를 구한다.
Spreadsheet& Spreadsheet::operator=(const Spreadsheet& rhs)
{
Spreadsheet temp{ rhs }; // 모든 작업을 임시 인스턴스에서 처리한다.
swap(temp); // 익셉션을 던지지 않는 연산에서만 작업을 처리한다.
return *this;
}
이 코드에서는 복제 후 맞바꾸기 구문을 적용했다.
먼저 오른쪽 항의 복제 버전인 temp를 만든 뒤 현재 객체와 맞바꾼다.
대입 연산자를 구현할 떄는 이 패턴에 따르는 것이 바람직하다.
그래야 익셉션에 대한 안전성을 높일 수 있다.
다시 말해 익셉션이 발생하더라도 Spreadsheet 객체는 변하지 않는다.
이렇게 구현하는 과정은 다음 세 단계로 구성된다.
- 1단계 : 임시 복제본을 만든다.
이렇게 해도 현재 Spreadsheet 객체의 상태가 변경되지 않는다.
따라서 이 과정에서 익셉션이 발생해도 문제가 되지 않는다. - 2단계 : swap()함수를 이용하여 현재 객체를 생성딘 임시 복제본으로 교체한다.
swap()함수는 익셉션을 전혀 던지지 않는다. - 3단계 : swap()으로 인해 원본 객체를 담고 있는 임시 객체를 제거하여 메모리를 정리한다.
복제 후 맞바꾸기 구문을 적용하지 않고
대입 연산자를 구현한다면 효율과 정확성을 위해 대입 연산자의 첫 줄에서
자기 자신을 대입하는지 검사하는 코드를 작성한다.
예를 들면 다음과 같다.
Spreadsheet& Spreadsheet::operator=(const Spreadsheet& rhs)
{
// 자기 자신을 대입하는지 검사한다
if (this == &rhs) { return *this; }
// ...
return *this;
}
복제 후 맞바꾸기 구문을 적용하면 자기 자신을 대입하는지
검사하는 코드를 작성하지 않아도 된다.
대입 연산자를 구현할 때 코드 중복을 방지하고
익셉션 안전성을 높이도록 복제 후 맞바꾸기 구문을 적용한다.
복제 후 맞바꾸기 구문은 대입 연산자 외에도 적용 가능하다.
연산이 여러 단계로 구성되어 있을 때 모두 정상적으로 처리하거나
중간에 문제가 생기면 아무 것도 하지 않아야 하는 연산이라면 어디든지 적용할 수 있다.
대입과 값 전달 방식 금지
때로는 클래스에서 메모리를 동적으로 할당할 때
아무도 그 클래스의 객체에
복제나 대입을 할 수 없게 만드는 게 가장 간편한 경우가 있다.
이렇게 하려면 명시적으로
operator=와 복제 생성자를 명시적으로 삭제하면 된다.
그러면 이 객체를 값으로 전달하거나,
함수나 메서드에서 이 객체를 리턴하거나,
이 객체에 뭔가를 대입할 때 컴파일 에러가 발생한다.
이런 식으로 대입과 값 전달 방식을 금지하려면
아래 예제 코드와 같이 클래스를 정의하면 된다.
class Spreadsheet
{
public:
Spreadsheet(size_t width, size_t height);
Spreadsheet(const Spreadsheet& src) = delete;
~Spreadsheet();
Spreadsheet& operator=(const Spreadsheet& rhs) = delete;
// 나머지 코드 생략
}
delete로 지정한 메서드를 구현할 필요는 없다.
컴파일러는 이러한 메서드를 호출하는 것을 허용하지 않기 때문에
링커는 이렇게 지정된 메서드를 전혀 참조하지 않는다.
만약 이렇게 작성된 객체에 어떤 값을 대입하거나 복제를 시도한다면
컴파일 에러가 발생할 것이다.
'프로그래밍 언어 > C & C++ 정리' 카테고리의 다른 글
이동 의미론으로 이동 처리하기 (0) | 2025.04.01 |
---|---|
포인터를 사용하는 이유 토막 정리 (0) | 2025.03.29 |
대입 연산자 (0) | 2025.03.04 |
생성자 (0) | 2025.02.19 |
동적 메모리 다루기 (2) | 2025.02.04 |