C++ 객체 초기화와 복사 이동

네이버 블로그에 작성한 Modern CPP — OOP (2025-04-24)를 바탕으로 설명과 예제를 보완했다.

클래스의 생성·복사·이동을 이해하려면 객체가 언제 만들어지고 어떤 자원을 소유하는지부터 구분해야 한다. 연산자 모양이 같아도 Cat b = a;는 생성이고, 이미 존재하는 객체에 대한 b = a;는 대입이다.

멤버 초기화와 대입은 다르다

생성자 본문이 시작되기 전에 기반 클래스와 멤버의 초기화가 진행된다. 본문에서 멤버에 값을 대입하면 이미 초기화된 멤버를 변경하는 것이다. 멤버 이니셜라이저 리스트는 그 멤버를 원하는 값으로 직접 초기화한다.

초기화 순서는 이니셜라이저 리스트에 적은 순서가 아니라 클래스의 멤버 선언 순서를 따른다. 앞 멤버가 뒤 멤버의 아직 초기화되지 않은 값에 의존하지 않도록 한다. 초기화 도중 예외가 발생하면 이미 생성이 끝난 멤버는 정리되지만 완성되지 않은 객체 자신의 소멸자는 호출되지 않는다.

자원 관리 객체에 맡기기

아래 C++17 예제는 문자열을 std::string이 소유하게 한다. 수동으로 버퍼를 할당하지 않으므로 소멸자나 복사·이동 연산을 직접 구현할 필요가 없다.

#include <iostream>
#include <stdexcept>
#include <string>
#include <utility>

class Cat {
    std::string name_;
    int age_;
public:
    explicit Catstring name, int age = 0
        : name_move(name)), age_(age {
        if (age < 0) throw std::invalid_argument("negative age");
    }
    const std::string& name() const { return name_; }
    int age() const { return age_; }
    void birthday() { ++age_; }
};

int main() {
    Cat first{"Kitty", 1};
    Cat second = first;              // 복사 생성
    second.birthday();
    Cat third = std::move(first);    // 이동 생성
    std::cout << second.name() << ' ' << second.age() << '\n';
    std::cout << third.name() << ' ' << third.age() << '\n';
    third = second;                 // 복사 대입
    std::cout << third.age() << '\n';
}
c++ -std=c++17 -Wall -Wextra -pedantic objects.cpp -o objects
./objects
Kitty 2
Kitty 1
2

멤버들이 각자의 자원 관리를 수행하게 하고 특별 멤버 함수를 직접 정의하지 않는 설계가 Rule of Zero다. 반환한 name()의 참조는 해당 Cat 객체보다 오래 보관할 수 없다. 복사본이 필요하면 호출자가 문자열 값을 복사한다.

복사는 항상 깊은 복사가 아니다

컴파일러가 생성하는 복사 연산은 멤버별 복사를 수행한다. std::string은 문자열의 값 의미를 제공하지만, raw pointer 멤버는 주소를 복사한다. 그 주소가 가리키는 메모리를 자동으로 새로 할당해서 복제하지 않는다.

포인터가 관찰용인지 소유자인지도 구분해야 한다. 포인터가 있다는 이유만으로 다섯 특별 멤버 함수를 무조건 직접 구현하는 것은 아니다. 소유권을 직접 관리해야 한다면 복사·이동·소멸의 계약을 함께 정하고, 복사를 금지하는 것이 맞다면 = delete를 사용한다. 자원을 적절한 표준 타입에 맡길 수 있는지 먼저 확인한다.

std::move가 실제 이동을 보장하지는 않는다

std::move는 인자를 이동 가능한 표현식으로 변환하는 도구다. 그 자체가 메모리를 옮기거나 원본을 비우지 않는다. 실제 호출되는 생성자·연산자가 동작을 결정한다. 이동 연산이 없거나 const 조건 등이 맞지 않으면 복사가 선택될 수 있다.

표준 라이브러리의 이동 후 객체는 별도 약속이 없는 경우 유효하지만 값이 지정되지 않은 상태다. 위 예제도 이동한 first.name()이 반드시 빈 문자열이라고 검사하지 않는다. 사용자 정의 타입에서는 이동 뒤에도 지킬 불변식을 설계해야 한다.

이동 연산이 항상 예외를 던지지 않는 것도 아니다. 멤버 연산과 구현을 확인하고 noexcept 계약을 정한다. 예외 안전성과 RAII에서 실패 시 상태를 다루는 방법을 이어서 볼 수 있다.

클래스 기능을 사용할 때 구분할 것

기능 확인할 의미
private 외부에서 직접 접근하지 못하게 하고 불변식은 공개 연산에서 유지
const 멤버 함수 그 함수에서 객체를 변경하지 않는 인터페이스. 동기화까지 보장하지는 않음
mutable 논리적으로 같은 값을 유지하면서 캐시·동기화 상태 등을 변경할 때 검토
explicit 생성자나 변환 연산자의 원치 않는 암시적 변환 제한
static 멤버 객체별 멤버와 구분되는 클래스 관련 상태·함수. 동시 접근 시 별도 조율 필요
오버로딩 인자 타입 등에 따라 컴파일 과정에서 함수 선택
가상 함수 오버라이딩 기반 클래스 인터페이스를 통한 동적 디스패치. override로 의도 확인

상속은 단순히 구현을 공유하기 위한 유일한 수단이 아니다. 값으로 멤버를 갖는 합성과 비교하고, 기반 클래스 포인터로 객체를 삭제하는 설계라면 소멸자 계약도 확인한다. 연산자 오버로딩 역시 사용자가 예상하는 의미를 유지할 때 사용한다.

자료: 복사·이동 생성자, 생성자 초기화, 이동 후 표준 라이브러리 타입, C++ Core Guidelines — 특별 멤버 함수