C++프로그래밍C++ 개발자

클래스 내에서 소멸자의 기본 빈 정의가 소멸자 자체가 사소하더라도 암묵적인 이동 연산을 억제하는 이유는 무엇인가요?

Hintsage AI 어시스턴트로 면접 통과

질문에 대한 답변

역사: C++98에서는 자원 관리를 위한 세 가지 규칙(Rule of Three)이 따랐습니다. 즉, 하나의 클래스가 사용자 정의 소멸자, 복사 생성자 또는 복사 대입 연산자가 필요하면, 세 가지 모두 필요할 가능성이 높습니다. C++11에서 이동 의미론이 도입되면서, 이는 다섯 가지 규칙(Rule of Five)으로 변경되었고, 이동 생성자와 이동 대입 연산자가 추가되었습니다. 표준 위원회는 보수적인 접근 방식을 선택했습니다. 소멸자를 선언하면(사소한 것이라 하더라도) 이동 연산의 암묵적 생성을 억제하여 소멸자가 관리하는 자원의 얕은 이동을 방지합니다.

문제: 클래스 정의 내부에서 ~MyClass() = default;라고 작성하면 "사용자 정의" 소멸자를 생성합니다. C++ 표준([class.copy.ctor]/3)에 따르면, 소멸자의 존재는 이동 생성자와 이동 대입 연산자의 암묵적 선언을 억제합니다. 그 결과, 컴파일러는 클래스를 복사 전용으로 간주하고, 실제 작업을 수행하지 않는 소멸자에도 불구하고 std::vector 재할당 또는 값으로 반환 최적화 시 비싼 복사 의미론으로 조용히 되돌아갑니다.

해결책: 암묵적인 이동 생성을 유지하려면, 소멸자는 클래스 내부에서만 선언하고, 기본 정의는 외부에서 제공하십시오:

class Optimized { public: ~Optimized(); // 여기서만 선언됨 std::array<char, 4096> buffer; }; Optimized::~Optimized() = default; // 외부에서 정의됨

이렇게 하면 소멸자가 컴파일러가 이동을 생성하기로 결정하는 지점에서 "사용자 제공"되지만 "사용자 선언"되지 않습니다. 대안으로는 다섯 개의 특별 멤버를 모두 명시적으로 기본값으로 설정하거나, 바람직하게는 원시 자원을 std::unique_ptr나 컨테이너로 대체하여 제로 규칙(Rule of Zero)을 따릅니다.

실생활의 상황

우리는 MarketDataPacket 객체를 처리하는 고주파 거래 엔진에서 이 문제를 경험했습니다. 이 클래스는 네트워크 데이터를 위해 고정된 4KB 버퍼를 보유하고 있었습니다:

class MarketDataPacket { public: ~MarketDataPacket() = default; // "명확성"을 위해 헤더에 작성됨 char buffer[4096]; };

C++11로 마이그레이션한 후, 지연 프로파일링에서 패킷을 값으로 반환하는 동안 CPU 사이클의 40%가 memcpy에서 소모되는 것으로 나타났습니다. 문제의 원인은 클래스 내에서 기본 값으로 설정된 소멸자가 의도하지 않게 암묵적 이동을 삭제하고 std::vector 성장 및 함수 반환 시 복사를 강제했기 때문입니다.

해결책 1: noexcept 이동 생성자 및 대입을 명시적으로 선언합니다. 이는 이동을 활성화하여 성능 문제를 즉시 해결합니다. 그러나 이러한 기능을 추가할 때 수동으로 유지해야 하며, 원시 포인터가 포함되어 있는 경우 예외 사양 불일치의 위험이 있으며, 제로 규칙을 위반하는 보일러플레이트가 추가됩니다.

해결책 2: 소멸자 정의를 .cpp 파일로 이동하여 MarketDataPacket::~MarketDataPacket() = default;로 작성합니다. 이를 통해 컴파일러 생성 이동을 복원하면서 소멸자는 사소한 상태를 유지합니다. 제로 오버헤드 추상화를 유지하고 사용되지 않는 객체에 대한 소멸자 호출 생략과 같은 컴파일러 최적화를 허용합니다. 단점은 별도의 컴파일 단위가 필요하다는 점입니다. 그러나 이는 수용 가능했습니다.

해결책 3: 원시 버퍼를 std::vector<uint8_t> 또는 **std::unique_ptrstd::byte[]**로 교체합니다. 이는 완벽한 제로 규칙 준수를 달성합니다. 그러나 이는 마이크로초 민감한 거래 경로에서 캐시 지역성이 중요하기 때문에 불리한 간접 또는 힙 할당 오버헤드를 도입합니다.

우리는 해결책 2를 선택했습니다. 기본값 설정을 클래스 외부로 이동하여 암묵적 이동을 복원하고 패킷 처리 지연을 12μs에서 3μs로 줄이며, 공격적인 컴파일러 최적화를 허용하는 사소한 파괴성을 유지했습니다.

후보들이 종종 놓치는 점

클래스 내 및 클래스 외에서 기본값을 설정할 때 컴파일러가 이를 구분하는 이유는 무엇인가요?

차이는 의미론이 아니라 구문적입니다. **C++**는 클래스 정의에 대해 단일 패스 구문 분석 모델을 사용합니다. 컴파일러가 클래스의 닫는 중괄호에 도달하면 암묵적인 이동 연산을 생성할지 여부를 결정해야 합니다. 내부에 = default가 보이면, 그 시점에서 소멸자는 "사용자 정의"가 되어 [class.copy]/7에 따른 억제 규칙이 발동합니다. 컴파일러는 이 결정을 변경하기 위해 외부 정의를 "미리 볼" 수 없습니다. 이는 **C++**의 컴파일 모델의 기본 제약입니다.

소멸자에 noexcept를 표시하면 암묵적 이동이 복원되나요?

아니요. 암묵적 이동 생성 억제는 소멸자가 사용자 정의인지 여부에만 의존하며, 예외 사양과는 관련이 없습니다. 이동을 사용하는 것이 std::vector 재할당에 필수적이지만, 클래스 내부의 기본값 소멸자에 noexcept를 추가한다고 해서 삭제된 이동 연산이 복원되지는 않습니다. 정의를 외부로 이동하거나 이동을 명시적으로 기본값으로 설정해야 합니다.

사용자 정의 소멸자가 집합 초기화에 미치는 영향은 무엇인가요?

사용자 정의 소멸자가 있는 클래스는 집합체가 되지 않습니다. 이는 이동을 잃는 것보다 더 파괴적일 수 있습니다. 지정 초기자(C++20) 및 명시적 생성자 없이 중괄호로 둘러싸인 초기화 목록을 사용할 수 있는 능력을 잃게 됩니다. 많은 개발자들이 집합 초기화가 작동할 것이라고 기대하고 실패했을 때 놀랐습니다:

struct Config { ~Config() = default; // 집합체 깨짐 int value; }; // Config c{42}; // 오류: 일치하는 생성자가 없음

이는 사용자 정의 소멸자의 존재가 클래스가 타입 시스템에서 비사소한 파괴 의미론을 가지도록 강제하여, 실제 복잡성과 상관없이 집합체 상태를 탈락시킴으로써 발생합니다.