27. Rule of Three/Five/Zero: 사용자 정의 소멸자/복사가 이동 생성자에 미치는 영향
난이도 중내 리뷰 · C++
해설 · C++
해설 — Rule of Three/Five/Zero: 사용자 정의 소멸자/복사가 이동 생성자에 미치는 영향
난이도: 중상
요약
사용자가 소멸자(또는 복사 생성자/복사 대입 연산자) 중 하나라도 직접 선언하면, 컴파일러는 이동 생성자와 이동 대입 연산자를 암시적으로 생성하지 않는다(정확히는 "정의되지 않음(not declared)" 처리되며 "삭제(deleted)"와는 미묘히 다르다). 하지만 이것이 is_move_constructible을 false로 만들지는 않는다 — 이동 생성자가 없으면 오버로드 해석이 복사 생성자로 폴백하기 때문에, "이동 가능해 보이지만 실제로는 항상 복사가 실행되는" 함정이 생긴다. 이동을 되살리려면 이동 생성자/대입을 = default로 명시 선언해야 한다.
핵심 개념
- 암시적 특수 멤버 함수 생성 규칙(C++11 이후): 사용자가 소멸자, 복사 생성자, 복사 대입 연산자 중 하나라도 선언하면, 이동 생성자와 이동 대입 연산자는 컴파일러에 의해 암시적으로 선언되지 않는다(즉 그런 함수가 아예 없는 것처럼 취급됨. "deleted"로 명시적으로 선언되는 것과는 구분되지만 최종 효과는 "이동 오버로드가 없다"는 점에서 같다).
- 오버로드 해석에서의 폴백: 이동 생성자가 없는 상태에서
T(std::move(x))나 rvalue를 넘기는 초기화가 일어나면, 컴파일러는 "이동 전용 오버로드가 없으니 에러"를 내는 게 아니라 복사 생성자(const T&매개변수)로 rvalue를 바인딩해 복사를 수행한다.const T&는 rvalue도 바인딩할 수 있기 때문이다. 그 결과std::is_move_constructible<T>::value는 여전히 true로 나온다 — "이동 가능"이라는 타입 특성이 "실제로 이동 연산이 존재한다"를 보장하지 않는다는 것이 이 문제의 핵심 함정이다. std::vector의 강한 예외 보장과noexcept이동:vector가 재할당할 때 원소를 이동할지 복사할지는std::move_if_noexcept의 판단을 따른다 — 이동 생성자가noexcept이면 이동을 사용하고, 이동 생성자가 없거나noexcept가 아니면(예외를 던질 수 있으면) 강한 예외 보장을 지키기 위해 복사를 사용한다.
단계별 내부 동작 (실측 기반, g++ 11, -std=c++17)
실제 컴파일·실행 결과:
[static-check] ItemBagLoud: is_nothrow_move_constructible=0
[static-check] ItemBagCopyOnly: is_nothrow_move_constructible=0
[static-check] ItemBagExplicit: is_nothrow_move_constructible=1
[ItemBagLoud] is_move_constructible=1 copyCount=12 moveCount=0
[ItemBagCopyOnly] is_move_constructible=1 copyCount=12 moveCount=0
[ItemBagExplicit] is_move_constructible=1 copyCount=0 moveCount=12
ItemBagLoud(소멸자만 사용자 정의, 복사도 직접 선언): 사용자 정의 소멸자가 있으므로 이동 생성자/이동 대입은 암시적으로 생성되지 않는다. 그러나 복사 생성자를 직접 선언해두었으므로,push_back(T(...))이나 재할당 시 이동이 필요한 모든 자리에서 복사 생성자로 자동 폴백된다.is_move_constructible은 true(복사 생성자가 그 자리를 대신 채워주므로)이지만is_nothrow_move_constructible은 false(복사 생성자는noexcept가 아니므로)다. 실측 결과moveCount=0,copyCount=12— 이동 생성자가 한 번도 호출되지 않고 전부 복사로 처리됨을 확인했다.ItemBagCopyOnly(복사 생성자/복사 대입만 사용자 정의, 소멸자는 선언 안 함): 이 경우도 결과는ItemBagLoud와 동일하다(moveCount=0,copyCount=12). 사용자 정의 복사 생성자가 존재하는 것 자체가 이미 이동 생성자의 암시적 생성을 막기 때문이다 — 즉 "소멸자가 있어야만" 이 규칙이 적용되는 게 아니라, 복사 생성자나 복사 대입 연산자 중 하나만 선언해도 이동 연산의 암시적 생성이 억제된다는 것을 실측으로 확인했다.ItemBagExplicit(소멸자 선언 + 이동 생성자/대입을= default처럼 명시적으로 직접 정의): 이동 생성자·이동 대입을 명시적으로 선언(코드에서는 직접 몸체를 작성하되noexcept로 표시)했으므로, 이 클래스는 정상적으로 이동 가능하다.is_nothrow_move_constructible=1이고, 실측 결과copyCount=0,moveCount=12— 재할당 시 전부 이동으로 처리됨을 확인했다.copyCount가 12로 나오는 이유(재할당 성장 패턴): libstdc++의vector는 용량이 부족하면 대략 2배로 성장한다.n=5일 때 capacity는0→1→2→4→4→8순으로 변한다(4번째 push까지는 capacity 4를 유지, 5번째 push에서 8로 성장). 각push_back(T(...))호출은 (a) 재할당이 필요하면 기존 원소 개수만큼 복사/이동 + 새 원소 1개 삽입, (b) 재할당이 필요 없으면 새 원소 1개 삽입만 발생시킨다. 5회의 push에서 복사/이동 횟수는 각각1(재할당,0개+신규1) + 2(재할당,기존1+신규1) + 3(재할당,기존2+신규1) + 1(재할당없음,신규1) + 5(재할당,기존4+신규1) = 1+2+3+1+5 = 12로, 실측값과 정확히 일치한다. 이 산식은ItemBagLoud/ItemBagCopyOnly(전부 복사로 처리)와ItemBagExplicit(전부 이동으로 처리) 모두 동일하게 적용되며, 실제로 세 클래스 모두 총 연산 횟수는 12로 같고 그 연산이 복사냐 이동이냐만 다르다.
흔한 오해·함정
- "소멸자를 정의하지 않고 복사 생성자만 정의했으니 이동에는 영향이 없을 것"이라는 오해 — 실측에서 보듯 복사 생성자/복사 대입 연산자를 사용자가 선언하는 것 자체만으로도 이동 생성자의 암시적 생성이 억제된다(Rule of Three의 "셋 중 하나라도 정의하면 나머지 둘도 고려하라"는 원칙이 이동에까지 영향을 미치는 지점).
- "
is_move_constructible<T>::value가 true이니 이 타입은 실제로 이동된다"는 오해 — 이 타입 특성은 "이동처럼 보이는 초기화 구문이 컴파일된다"는 것만 보장하며, 실제로 이동 생성자가 호출되는지 복사 생성자가 대신 호출되는지는 별개다. 성능이 중요한 코드에서는is_nothrow_move_constructible을 확인하거나 실측(카운터)으로 검증해야 한다. - "이동 생성자가 없으면 컴파일 에러가 날 것"이라는 오해 — 실제로는 조용히 복사로 폴백되므로 컴파일은 성공하고 성능 저하(불필요한 깊은 복사)만 조용히 발생한다. 이것이 이 문제 유형이 실무에서 위험한 이유다 — 컴파일러가 경고 없이 최적이 아닌 코드를 통과시킨다.
- "Rule of Five를 지키려고 소멸자, 복사 생성자/대입, 이동 생성자/대입을 전부 직접 작성해야 한다"고만 알고 있는 경우, 애초에 특수 멤버 함수를 하나도 선언할 필요가 없다면(Rule of Zero) 컴파일러가 자동으로 올바른 이동/복사를 전부 생성해준다는 대안을 놓치기 쉽다.
ItemBagLoud의 소멸자가 로그조차 남기지 않는 빈 몸체라면, 애초에 소멸자를 선언하지 않는(Rule of Zero) 것이 이 모든 함정을 피하는 가장 좋은 방법이다.
면접 포인트
- "사용자 정의 소멸자가 있으면 이동 생성자가 어떻게 되는가"라는 질문에 "삭제된다"고 단순히 답하지 않고, "암시적으로 선언되지 않아 이동이 필요한 자리에서 복사로 폴백된다"고 정확히 설명할 수 있는가.
is_move_constructible과is_nothrow_move_constructible의 차이, 그리고std::vector가 재할당 시 이 둘 중 무엇을 근거로 복사/이동을 선택하는지(std::move_if_noexcept) 설명할 수 있는가.- Rule of Zero를 언제 적용할 수 있는지(멤버가 전부 리소스를 스스로 관리하는 타입일 때) 판단할 수 있는가.
- 이 함정을 실무에서 어떻게 탐지할 것인가(정적 분석,
static_assert(std::is_nothrow_move_constructible_v<T>)로 의도 강제, 또는 카운터를 이용한 유닛 테스트)에 대한 아이디어를 제시할 수 있는가.