책/Effective C++

[Effective C++] Cp 5. 컴파일러가 만든 함수도 다시보자

Sonji 2026. 4. 13. 02:47

이 항목의 핵심은 간단하다.

클래스의 생성/복사/대입/소멸 동작을 내가 명시하지 않으면 컴파일러가 "합리적이라고 판단한 기본 동작"을 넣어 준다.
문제는 이 기본 동작이 "컴파일은 되지만 의도는 틀린 코드"를 만들 수 있다는 점이다. 특히 리소스 소유권(메모리, 파일, 소켓, 뮤텍스)을 다루는 클래스에서 위험하다.

1. 컴파일러가 자동으로 만들 수 있는 함수

C++ 클래스에서 특별히 선언하지 않으면 컴파일러가 다음 멤버 함수를 자동으로 선언할 수 있다.

  • 기본 생성자(default constructor)
  • 소멸자(destructor)
  • 복사 생성자(copy constructor)
  • 복사 대입 연산자(copy assignment operator)

C++11 이후에는 이동 의미(move semantics)가 추가되어 아래도 상황에 따라 자동 생성된다.

  • 이동 생성자(move constructor)
  • 이동 대입 연산자(move assignment operator)

주의할 점은 "항상 자동 생성"이 아니라 "조건이 맞을 때 자동 생성"이라는 점이다. 일부 함수를 직접 선언하면 다른 함수의 자동 생성이 억제되거나 삭제(= delete)될 수 있다.

2. 자동 생성 동작이 왜 문제를 만들까

자동 생성된 복사/대입은 기본적으로 "멤버별 복사(memberwise copy)"다.
멤버가 값 타입(int, std::string, std::vector)이면 보통 문제 없다.하지만 "소유권 있는 포인터"가 멤버면 얕은 복사가 일어나기 쉽다.

class FileHandle
{
private:
    FILE* fp;

public:
    explicit FileHandle(FILE* f)
        : fp(f)
    { }

    ~FileHandle()
    {
        if (fp != nullptr)
        {
            fclose(fp);
        }
    }
};

위 코드에서 복사 생성자/복사 대입을 직접 정의하지 않으면 fp 주소값만 복사된 객체가 생길 수 있다. 그 결과 다음 문제가 생긴다.

  • 두 객체가 같은 파일 핸들을 닫으면서 이중 해제 발생
  • 한 객체가 먼저 닫아 버린 뒤 다른 객체가 잘못된 핸들 접근
  • 소멸 순서에 따라 간헐적 버그(재현 어려움)

3. "자동 생성/삭제"가 갈리는 대표 조건

이하는 개발 중 자주 걸리는 규칙들이다.

  • 참조 멤버(T&)가 있으면 복사 대입이 자연스럽게 어렵다.
  • const 멤버가 있으면 복사 대입 시 재할당이 불가능해 제약이 생긴다.
  • 사용자 정의 소멸자/복사 연산자 선언은 이동 연산 자동 생성을 막을 수 있다.
  • 멤버/기반 클래스가 복사 불가면 해당 클래스 복사도 불가가 된다.

즉, 나는 하나만 건드렸는데 다른 연산이 갑자기 delete되는 현상이 실제로 발생하므로, 클래스 인터페이스만 보고도 복사/이동 가능 여부를 명시적으로 확인해야 한다.

4. Rule of Three / Five 관점에서 정리

고전적으로는 Rule of Three가 중요하다.

  • 소멸자
  • 복사 생성자
  • 복사 대입 연산자

이 셋 중 하나를 직접 작성해야 하는 클래스라면, 대개 나머지 둘도 함께 설계해야 한다는 뜻이다.

C++11 이후에는 이동 연산까지 포함해 Rule of Five로 확장한다.

  • 소멸자
  • 복사 생성자
  • 복사 대입 연산자
  • 이동 생성자
  • 이동 대입 연산자

5. 권장 대응 방식

가장 안전한 우선순위는 다음과 같다.

  1. 리소스를 직접 다루지 말고 RAII 표준 타입을 먼저 사용
  2. 복사가 불필요한 타입은 명시적으로 복사 금지
  3. 복사가 필요하면 깊은 복사 정책을 코드로 분명히 구현
  4. 이동만 허용할지, 복사+이동 모두 허용할지 의도 명시

복사 금지 예시는 아래처럼 표현한다.

class Uncopyable
{
private:
    Uncopyable(const Uncopyable&) = delete;
    Uncopyable& operator=(const Uncopyable&) = delete;

public:
    Uncopyable() = default;
    ~Uncopyable() = default;
};

6. 깊은 복사가 필요한 경우의 형태

소유 포인터를 반드시 써야 한다면 복사 생성자/복사 대입에서 "새 자원 생성 + 내용 복사"를 직접 정의해야 한다.

class Buffer
{
private:
    std::size_t size;
    int* data;

public:
    explicit Buffer(std::size_t n)
        : size(n), data(new int[n]())
    { }

    ~Buffer()
    {
        delete[] data;
    }

    Buffer(const Buffer& rhs)
        : size(rhs.size), data(new int[rhs.size])
    {
        std::copy(rhs.data, rhs.data + size, data);
    }

    Buffer& operator=(const Buffer& rhs)
    {
        if (this == &rhs)
        {
            return *this;
        }

        int* newData = new int[rhs.size];
        std::copy(rhs.data, rhs.data + rhs.size, newData);

        delete[] data;
        data = newData;
        size = rhs.size;

        return *this;
    }
};

이런 코드가 반복된다면 원시 포인터 대신 std::vector<int>를 쓰는 편이 훨씬 안전하다.

7. 정리

컴파일러의 자동 생성 함수는 편의 기능이지 설계 의도를 대신해 주는 기능이 아니라는 점을 잊지말자.
클래스가 무엇을 소유하고, 복사/이동/소멸을 어떻게 해야 하는지 직접 결정하고, 그 결정을 = default/= delete/사용자 정의 함수로 명시하는 습관이 필요하다.