C++/개념(Concept)

deleted 함수와 이동 연산의 overload resolution

kim선달 2021. 5. 30. 15:47
Select Language

deleted 함수와 이동 연산의 overload resolution


1. 설명

특수 멤버 함수는 경우에 따라 암시적으로 자동생성되지 않을 수도 있고, 혹은 명시적으로 삭제할수도 있습니다.

이러한 두 경우에 따라서 이동 연산(이동 생성, 이동 대입)이 overload 에 참여할 수도, 참여하지 않을 수도 있습니다.

그러면 경우에 따라 이동을 의도했지만 실제로는 복사가 일어나게 될 수 있으며, 성능 저하(대게는 프로그래머가 의도하지 않은)가 일어날 수 있습니다.

 

본 글에서는 편의를 위해 암시적으로 자동생성되지 않은 경우를 암시적 삭제로 부르도록 하겠습니다.

 


2. 예시

우선, 객체가 trivial 한 복사 및 이동연산을 지원한다면, 이동 연산은 무조건 복사 연산으로 대체됩니다.

사실 당연한 것입니다.

trivial한 복사 및 이동이 가능한 fundamental type들은 "값을 잃었다" 라는게 정의될 수 가 없는 것이죠.

int가 값을 잃으면 어떠한 값을 가져야 할까요? 0? NaN? -inf?

이걸 둘째치고서라도, 속도의 향상을 위해 복사 대신 이동 연산을 정의하는 는 것인데 저런 "값을 잃었음" 이라는 flag를 대입하는게 단순 복사보다는 무조건 느리겠죠.

 

여기서는 trivial 하지 않은 객체에 대해 다루도록 하겠습니다.

 

 

 

이동 연산이 암시적으로 삭제된 경우, 해당 연산은 overload에 참여하지 않습니다.

즉, std::move를 통해 생성을 하면 무조건 복사 생성자를통해 이동이 아닌 복사가 이루어지게 됩니다(복사 생성자가 있다는 가정 하에).

// 이동 생성자가 암시적으로 삭제된 클래스
struct A {
  A() = default;
  A(A const&) = default;
  // 복사 생성자를 선언함으로써 이동 생성자의 자동 생성을 막음(암시적으로 삭제됨)
  
  std::string hello = "hello";
}

A a1;
A a2(std::move(a1)); // 복사 생성자가 호출됨
std::cout << a1.hello << std::endl; // 데이터가 그대로 남아있음

의 예시에서 보면 a1을 이용해 a2를 이동 생성하려고 의도했지만, 실제로는 복사가 이루어진 모습입니다.

위의 경우에서 이동 생성자를 default로 선언하거나, 복사 생성자를 없에버리면 정상적으로 이동 생성자가 호출이 되게 됩니다.

 

 

반면, 이동 연산이 명시적으로 삭제된 경우에는 std::move를 통해 생성을 하면 컴파일 에러가 나게 됩니다.

즉, 이동 연산 자체는 호출가능한 함수 목록에 올라가 있고 이를 호출시 컴파일 에러가 나는 구조가 되게 됩니다.

// 이동 생성자가 명시적으로 삭제된 클래스
struct B {
  B() = default;
  B(B const&) = default;
  B(B &&) = delete; // 이동 생성자를 명시적으로 삭제함!
  
  std::string hello = "hello";
}

B nm1;
B nm2(std::move(nm1)); // 컴파일 에러!

 

여기까지는 사실 조금만 생각해 보면 별 다른 문제가 없습니다.

그럼 이제 이 상황과 RVO를 한번 섞어보겠습니다.

// B는 이동 생성이 명시적으로 삭제된 클래스임
B getB_1() {
  return {};
}

B getB_2() {
  B b;
  return b; // 컴파일 에러!
}

B b1{getB_1()}; // C++17 이상만 컴파일 됨
B b2{getB_2()}; // 컴파일 에러!

우선 getB_2()의 경우 무조건 컴파일이 되지 않습니다.

이동생성이 명시적으로 삭제된 경우에는 NRVO에서 Copy/Move ellision이 작동하지 않기 때문이죠.

반면 getB_1()의 경우 Copy/Move ellision이 규칙으로 확정된 C++17 부터는 컴파일이 가능합니다.

대신 그 이전 버전에서는 컴파일러에 따라 컴파일 될 수도, 컴파일 되지 않을 수도 있습니다.

 

 

 

그럼 이제 "C++17이후의 unnamed rvo를 제외하면 명시적인 삭제가 원래 의도하던대로 동작하는것 아닌가?" 라고 생각하실 수 있습니다.

그런데 이런 예외 경우가 몇가지 더 있습니다.

바로 이러한 클래스들을 상속받을때는 또 얘기가 달라지게 됩니다.

 

먼저 이동 생성이 암시적으로 삭제된 A를 먼저 상속받아 보겠습니다.

struct A2 : A {
  A2() = default;
  A2(A2 const&) = default;
  A2(A2 &&) = default;
  
  std::string world = ", world!"
}

A2 a2_1;
A2 a2_2(std::move(a2_1)); // A2의 이동 생성자가 호출됨! A는 여전히 복사 생성자가 호출됨

복사 및 이동 생성자가 다 명시적으로 default로 선언되었습니다.

부모 클래스의 이동 생성자가 암시적으로 삭제 되었더라도 자식 클래스는 이동 생성자를 통해 생성되게 됩니다.

물론, 부모 클래스는 이동 생성자가 없기 때문에 부모 클래스는 여전히 복사 생성이 되게 됩니다.

 

이를 확인해보기 위해 출력을 해 보면 다음과 같습니다.

std::cout << a2_1.hello << a2_1.world << std::endl;
// hello

 

 

이번에는 이동 생성이 명시적으로 삭제된 B를 상속받아 보겠습니다.

// B는 이동 생성이 명시적으로 삭제된 클래스
struct B2 : B {
  B2() = default;
  B2(B2 const&) = default;
  B2(B2 &&) = default;

  std::string world = ", world!";
}

B2 b2_1;
B2 b2_2(std::move(b2_1)); // 복사 생성자가 호출됨!

B2도 마찬가지로 생성자들을 명시적으로 default로 선언해 줍니다.

이 경우 프로그래머는 이동 생성을 의도했지만, 실제로는 복사 생성이 이루어집니다.

 

이를 확인하기 위해 출력을 하면 다음과 같습니다

std::cout << b2_1.hello << b2_1.world << std::endl;
// hello, world!

 

즉, 부모는 이동 생성이 컴파일조차 되지 않지만, 자식을 이동생성 하려고 하는 경우 자식은 무조건 복사 생성자가 호출이 되게 됩니다.

이러한 경우 부모는 복사 생성자가 존재하므로 부모 또한 복사 생성이 이루어지게 됩니다.

서론에서 말씀드린것처럼, 자식 클래스의 이동생성자는 overload에 참여하지 않게 된 것입니다.

 

 

그렇다면 부모가 이동 생성이 명시적으로 삭제되어 있다면 이를 상속받는 모든 클래스들도 이동이 되지 않고 무조건 복사만 이루어지는 걸까요?

그렇지는 않습니다.

명시적으로 삭제된 이동 연산은 직계 자식의 이동 연산만 overload resolution에서 제외시키고, 더 밑의 자식들에게는 영향을 미치지 않습니다.

struct B3 : B2 {
  B3() = default;
  B3(B3 const&) = default;
  B3(B3 &&) = default;
  
  std::string hello_again = " hello again";
}

B3 b3_1;
B3 b3_2(std::move(b3_1)); // B3의 이동 생성자가 호출됨! 대신 B2는 복사 생성자가 호출됨

 

출력해보면 B3은 정상적으로 이동 생성되어 아래와 같이 hello_again이 날아간 것을 볼 수 있습니다.

std::cout << b3_1.hello << b3_2.world << b3_2.hello_again << std::endl;
// hello, world!

 

또한, 본 예시에서는 B2의 이동 생성자를 default로 선언했기 때문에 그런 것이고, 실제로 이러한 경우에는 사용자가 직접 이동 생성자를 작성해 주면 됩니다.

 

여기서는 생성자에 대해서만 다루었지만, 동일한 원리가 대입 연산자(operator=)에게도 적용됩니다.

 

또 이는 C++의 이동 연산을 복사 연산보다 우선시하는 규칙때문에 생기는 현상이므로,

복사 연산이 삭제된 경우에 복사 연산을 호출하려고 의도할 시에 이동 연산이 호출되거나,

복사 연산이 삭제된 클래스의 자식 클래스가 복사 연산이 기본 생성 되었을 때, 이 복사 연산의 호출이 가능한 경우는 생기지 않습니다.

 

 

결론을 내리자면 (생성자 및 대입 연산자 모두를 연산이라고 표현함),

  • 이동 연산이 암시적으로 삭제된 클래스는 이동 연산을 호출을 하려고 하면 실제로는 복사 연산이 호출된다
    • 이러한 클래스를 상속받는 클래스는, 이동 연산이 명시적으로 default로 선언되었거나 자동으로 생성되었다고 하더라도 이동 연산이 정상적으로 호출된다. 부모 클래스는 당연히 복사 연산이 호출된다.

  • 이동 연산이 명시적으로 삭제된 클래스는 이동 연산의 호출시 컴파일 에러가 난다(overload resolution에 참여함)
    • 이러한 클래스를 상속받는 클래스는, 이동 연산이 명시적으로 default로 선언되었거나 자동으로 생성된다면 이 클래스의 이동 연산은 overload resolution에 참여하지 않는다. 고로 무조건 복사 연산이 호출된다.
      여기서 이동 연산을 지원하고 싶다면, 프로그래머가 직접 작성해 주어야 한다.
    • 자식 클래스의 이동 연산의 overload resolution에 관여하는 행위는 바로 아래의 자식에게만 영향을 미친다.
      그 아래의 자식들은 이동연산(기본 생성 되었더라도)이 정상적으로 호출 된다

  • 복사 연산이 암시적으로 삭제된 클래스는 당연히 복사 연산을 호출할 수 없다.
    • 이러한 클래스를 상속받는 클래스는, 복사 연산을 default로 선언하거나 복사 연산이 자동생성 되었을 경우에도, 마찬가지로 당연히 이러한 복사 연산을 호출할 수 없다.

 


같이 보면 좋은 글

 

암시적으로 특수 멤버 함수가 삭제되는 경우

delete