Sonji-log
[Effective C++] Cp 6. 컴파일러가 만들어주는 함수를 원치 않으면 사용 금지시키자 본문
1. 이 항목이 왜 중요한가
C++에서는 내가 직접 쓰지 않아도 컴파일러가 몇몇 함수를 자동으로 만들어 주는 경우가 있다. 대표적으로 아래 두 가지가 자주 문제를 만든다.
- 복사 생성자(copy constructor)
- 복사 대입 연산자(copy assignment operator)
겉으로 보기에는 편리하다. 하지만 어떤 클래스는 "복사되면 안 되는 객체" 인데도, 아무 조치를 하지 않으면 복사가 가능해질 수 있다.
예를 들어, 아래와 같은 경우를 생각해 보자.
- mutex 같은 동기화 객체
- 파일 핸들, 소켓 핸들 같은 시스템 자원
- "오직 하나만 존재해야 하는" 객체(Singleton Pattern에 해당하는 성격의 객체를 생각하면 쉽다)
- 내부적으로 복사 의미가 애매하거나 위험한 객체
이런 객체를 실수로 복사하면 다음과 같은 문제가 생길 수 있다.
- 같은 자원을 여러 객체가 동시에 가리킨다.
- 누가 자원을 해제해야 하는지 애매해진다.
- 이중 해제(double free), 자원 누수(leak), 논리 오류가 생긴다.
핵심은 간단하다.
- 복사가 말이 안 되는 클래스는 아예 복사 자체를 막아야 한다.
- "아마 안 쓰겠지"가 아니라, 컴파일 단계에서 못 쓰게 만들어야 한다.
2. 자동 생성 함수는 항상 반가운 것이 아니다
2-1. 컴파일러는 빈칸을 채워 준다
클래스를 만들었는데 복사 생성자나 복사 대입 연산자를 직접 선언하지 않으면,
컴파일러가 필요에 따라 이 함수들을 만들어 줄 수 있다.
class Widget
{
public:
Widget() {}
};
겉보기에는 생성자 하나만 있는 클래스처럼 보이지만, 실제로는 복사 관련 함수도 생길 수 있다.
Widget w1;
Widget w2(w1); // 복사 생성
Widget w3;
w3 = w1; // 복사 대입
이게 문제가 없는 클래스라면 괜찮지만, 모든 클래스가 복사되어도 안전한 것은 아니다.
2-2. 멤버 단위 복사는 생각보다 단순하다
컴파일러가 자동으로 만드는 복사 동작은 대체로 멤버별 복사(memberwise copy) 다.
즉 객체 전체 의미를 깊게 이해하고 복사하는 것이 아니라, 각 멤버를 그냥 복사하는 쪽에 가깝다.
예를 들어 포인터가 멤버로 들어 있으면, 포인터가 가리키는 실제 대상까지 새로 복제해 주는 것이 아니라 주소값만 복사하는 식으로 동작할 수 있다.
class Person
{
private:
char* name;
public:
Person(char* name) : name(name) {}
};
이런 객체를 그냥 복사하면, 두 객체가 같은 name 메모리를 같이 가리키게 될 수 있다.
그러면 누가 메모리를 해제해야 하는지부터 꼬이기 시작한다.
다시 말해서, 자동 생성 함수는 편리하지만, 클래스 의미에 맞지 않는 복사까지 허용할 수 있다는 점이 위험하다.
3. "복사되면 안 되는 클래스"는 실제로 많다
3-1. 예: 잠금 객체
뮤텍스 같은 잠금 객체를 감싸는 클래스를 생각해 보자.
class Lock
{
private:
Mutex* mutexPtr;
public:
explicit Lock(Mutex* pm) : mutexPtr(pm)
{
lock(mutexPtr);
}
~Lock()
{
unlock(mutexPtr);
}
};
이 클래스의 의미는 "생성될 때 잠그고, 소멸될 때 푼다"에 가깝다. 그런데 이 객체가 복사되면 어떤 일이 벌어질까?
Lock l1(&m);
Lock l2(l1); // 이 복사가 과연 말이 될까?
문제가 바로 보인다.
l1과l2가 같은 뮤텍스를 관리하게 될 수 있다.- 둘 다 소멸될 때
unlock을 호출하려 들 수 있다. - 잠금의 소유권이 누구에게 있는지 애매해진다.
즉 이 클래스는 "복사 가능"보다는 "복사 금지" 가 맞다.
3-2. 예: 유일해야 하는 객체
어떤 객체는 설계 자체가 "하나의 실체"를 표현한다.
- 설정 관리자
- 로그 관리자
- 하드웨어 장치 핸들
이런 대상을 무심코 복사 가능하게 두면, 코드 사용하는 쪽에서는 "그냥 값처럼 복사해도 되나 보다"라고 오해하기 쉽다.
그래서 설계 의도를 복사가 가능하면 의미를 명확하게 제공하고, 불가능하면 아예 컴파일이 불가하도록 코드에 박아 넣어야 한다.
4. 해결 방법: 복사 관련 함수를 private으로 선언만 하자
책에서 제안하는 전통적인 방법은 아래와 같다.
4-1. 복사 생성자와 복사 대입 연산자를 private에 넣는다
class HomeForSale
{
private:
HomeForSale(const HomeForSale&);
HomeForSale& operator=(const HomeForSale&);
public:
HomeForSale() {}
};
이 코드는 두 가지를 노린다.
- 클래스 바깥에서 복사하려고 하면 접근이 안 된다.
- 정의를 제공하지 않으므로, 혹시 내부에서 잘못 써도 링크 단계에서 걸릴 수 있다.
4-2. 왜 선언만 하고 정의는 안 하는가
복사 금지가 목적이라면, 이 함수들이 호출될 일이 없어야 한다. 그래서 일부러 선언만 하고 정의는 만들지 않는다.
class HomeForSale
{
private:
HomeForSale(const HomeForSale&); // 선언만
HomeForSale& operator=(const HomeForSale&); // 선언만
};
이렇게 해 두면:
- 외부 코드가 복사 시도 →
private접근 에러 - 멤버 함수나 friend 함수가 실수로 사용 → 정의가 없어 링크 에러 가능
friend 함수가 낯설기도 하고 방식도 그닥 예뻐보이진 않지만, C++11 이전에는 매우 널리 쓰이던 방법이라고 한다.
5. 왜 public에 두고 "호출하지 마세요"라고 하면 안 되는가
가끔은 주석이나 문서만으로 복사를 막으려는 코드가 있다.
class HomeForSale
{
public:
HomeForSale() {}
// 복사하지 마세요!
};
이 방식은 거의 의미가 없다. 컴파일러는 주석을 읽지 않기 때문이다.
실제 개발에서는 아래 같은 일이 왕왕 생긴다.
- 다른 사람이 클래스 의도를 모른 채 복사한다.
- 시간이 지나서 작성자 본인도 설계를 잊는다.
- 컨테이너에 넣거나 함수 인자로 넘기다가 복사가 발생한다.
그래서 문서로만 막지 말고, 문법 차원에서 아예 방어해야 한다.
6. base class로 복사 금지 기능을 재사용할 수 있다
복사 금지 클래스가 많아지면, 매번 같은 코드를 반복하게 된다. 이럴 때는 복사 금지용 기반 클래스를 하나 만들어서 재사용할 수 있다.
class Uncopyable
{
private:
Uncopyable(const Uncopyable&);
Uncopyable& operator=(const Uncopyable&);
protected:
Uncopyable() {}
~Uncopyable() {}
};
이제 복사를 막고 싶은 클래스는 이 클래스를 상속받으면 된다.
class HomeForSale : private Uncopyable
{
public:
HomeForSale() {}
};
이 방식의 장점은 의도가 눈에 바로 들어오고, 중복 코드가 줄어들어서 여러 클래스에 같은 정책을 쉽게 적용할 수 있다는 점이다.
6-1. 생성자와 소멸자가 protected인 이유
Uncopyable은 직접 객체를 만들기 위한 클래스가 아니라, 다른 클래스의 기반 클래스로만 쓰기 위한 도구다. 그래서 생성자와 소멸자를 protected에 둔다.
7. 이 방법이 실제로 막는 것은 무엇인가
이 패턴은 주로 아래 두 동작을 막는다.
7-1. 복사 생성
HomeForSale h1;
HomeForSale h2(h1); // 에러
7-2. 복사 대입
HomeForSale h1;
HomeForSale h2;
h2 = h1; // 에러
즉 "새 객체를 기존 객체로부터 만드는 것"과 "이미 있는 객체에 다른 객체 값을 대입하는 것" 둘 다 금지된다.
한쪽만 막고 다른 쪽을 열어 두면 여전히 복사 의미가 새어 나갈 수 있다. 그래서 보통은 둘을 같이 막는다.
8. 핵심은 "설계 의도를 타입에 반영하라"는 것이다
내 코드도 그렇지만, 많은 초보 코드에서는 클래스 설계와 실제 문법이 따로 논다.
예를 들면:
- 개념적으로는 복사되면 안 되는 객체인데
- 문법상으로는 멀쩡히 복사 가능하다
이 상태가 제일 위험하다.
사용하는 쪽에서 틀린 방식으로 써도, 컴파일러가 막아주지 않기 때문이다.
좋은 클래스 설계는 복사 가능 여부에 따라 아래처럼 분기별로 방어를 잘 해두어야 한다.
- 복사 가능한 타입이면: 복사 동작을 자연스럽고 안전하게 제공한다.
- 복사 불가능한 타입이면: 애초에 복사 문법이 막혀 있어야 한다.
9. C++11 이후에는 = delete가 더 직접적이다
이번 6챕터는 전통적인 기법을 설명하지만, 현대 C++에서는 더 명확한 방법이 있다.
class HomeForSale
{
public:
HomeForSale() = default;
HomeForSale(const HomeForSale&) = delete;
HomeForSale& operator=(const HomeForSale&) = delete;
};
이 방식의 장점:
- 의도가 훨씬 바로 보인다.
private+ 선언만 하는 우회 기법보다 읽기 쉽다.- 컴파일 에러 메시지도 보통 더 직접적이다.
즉 요즘 C++에서는 보통 = delete가 더 좋은 선택이다. 다만 Effective C++가 쓰이던 시기의 배경을 이해하려면, private 선언 기법도 알아둘 가치가 있다.
9-1. 그래도 옛 기법을 알아야 하는 이유
실무에서는 오래된 코드베이스를 자주 만난다. 그 안에는 아직도 이런 형태가 남아 있을 수 있다.
class Uncopyable
{
private:
Uncopyable(const Uncopyable&);
Uncopyable& operator=(const Uncopyable&);
};
이걸 봤을 때 "왜 굳이 이렇게 복잡하게 썼지?"가 아니라, "아, 복사 금지 의도를 표현한 오래된 패턴이구나"를 바로 읽어낼 수 있어야 하겠다.
10. 실무에서 어떻게 판단하면 좋은가
클래스를 만들 때 한 번은 꼭 자문해 보는 편이 좋다.
10-1. 이 객체는 복사되어도 자연스러운가
예를 들어 Point, std::string 같은 값 타입은 복사가 자연스럽다. 복사본이 생겨도 의미가 크게 꼬이지 않는다.
10-2. 이 객체는 자원 소유권을 가지는가
파일, 소켓, 락, 데이터베이스 연결처럼 외부 자원을 직접 관리하면 복사 의미가 민감해진다.
반면 아래와 같은 경우는 특히 신중해야 한다.
- 진짜 복사가 필요하다면 복사 규칙을 직접 설계한다
- 아니면 복사를 금지한다
11. 항목 6 핵심 정리
반드시 기억할 것
- 컴파일러가 자동으로 만드는 복사 관련 함수가 항상 올바른 것은 아니다.
- 어떤 클래스는 설계상 복사되면 안 되므로, 복사 자체를 금지해야 한다.
- Effective C++의 전통적인 방법은 복사 생성자와 복사 대입 연산자를
private에 선언만 해 두는 것이다. - 같은 정책이 반복되면
Uncopyable같은 기반 클래스로 재사용할 수 있다. - 현대 C++에서는 보통
= delete가 더 명확하고 읽기 좋다.
'책 > Effective C++' 카테고리의 다른 글
| [Effective C++] Cp 5. 컴파일러가 만든 함수도 다시보자 (0) | 2026.04.13 |
|---|---|
| [Effective C++] Cp 4. 객체를 사용하기 전 반드시 초기화 (0) | 2026.04.09 |
| [Effective C++] Cp 3. const 사용의 습관화 (0) | 2026.04.08 |
| [Effective C++] Cp 2. #define 보다는 const, enum, inline (0) | 2025.03.13 |
| [Effective C++] Cp 1. C++는 언어들의 연합체 (0) | 2025.03.13 |
