SW 그리고 아빠 엔지니어링 중...

아는 만큼 보이고, 경험해봐야 알 수 있고, 자꾸 써야 내 것이 된다.

L C++

컴파일러와 페어 프로그래밍 - 특별 멤버 함수 편 🚀

보리남편 김 주부 2024. 8. 15. 20:52
💬 최근 AI 페어 프로그래밍 교육을 들으며, AI에게 도움받는 영역이 훨씬 높은 수준까지 이르렀다는 생각이 들었다. 특별 멤버 함수(Special Member Function)는 컴파일러가 사용자 코드 작성에 따라 생성 유무가 달라지기에 컴파일러와 페어프로그래밍이란 제목을 써봤는데, 아무튼 본 글에서는 컴파일러가 바라보는 특별 멤버 함수 생성 규칙에 대해 알아보자.

 

누가 특별 멤버 함수(Special Member Function) 인가?


 

우선 예제 코드를 통해 어떤 게 특별 멤버 함수 인지를 확인해 보자.

 

Example
struct Object
{
  std::string name;
  int data;
  Object() {};    //1.

  ~Object() {};   //2.

  Object(const Object& other) : name(other.name), data(other.data){};   //3.

  Object& operator=(const Object& other) { //4.
    if(this == &other)
        return *this;
    name = other.name;
    data   = other.data;
    return *this;
  }
  Object(Object&& other) noexcept //5.
    : name(std::move(other.name)), data(std::move(other.data)) {}

  Object& operator=(Object&& other) noexcept //6.
  {
    if(this == &other)
      return *this;
    name = std::move(other.name);
    data   = std::move(other.data);
    return *this;
  }
};

 

특별멤버함수는 총 6가지인데, 주석 하나하나가 어떤 특별멤버함수 인지를 알아보면 아래와 같다.

  1. 디폴트 생성자
  2. 소멸자
  3. 복사생성자
  4. (복사) 대입연산자
  5. 이동 생성자
  6. 이동 대입연산자

📜 다만, 컴파일러가 C++98에서는 1~4 까지만 기본으로 제공을 했고, 5, 6은 C++11부터 추가되어, 기본으로 생성해주고 있다.

 

컴파일러의 특별 멤버 함수(Special Member Function) 생성 규칙!


컴파일러가 특별 멤버 함수를 생성하는데 3가지 규칙이 있는데 하나씩 알아보자.

 

✔ 규칙 1

사용자가 '기본 생성자'를 만들면, 컴파일러는 '기본 생성자'를 제외한 5개의 특별 멤버 함수만 생성해 준다.

 

아래 예제는 '기본 생성자'만 정의를 했다.

  main 함수 내에 '복사'와 '이동' 동작이 되는지 확인해 보자.

Example
#include<iostream>
struct Object
{
    std::string name;
    int data;
    //Object() {}
    Object(const std::string& name, int data) : name(name), data(data) {}
}

int main()
{
    Object obj1("obj1", 10);
    Object obj2("obj2", 10);
    Object obj3 = obj1;                        //1.
    Object obj4 = std::move(obj2);             //2.
    std::cout << obj1.name << std::endl;
    std::cout << obj2.name << std::endl;
}
result
obj1  
     <- (빈공간)

 

주석의 내용을 확인해 보자

주석 1 : 복사 생성자가 호출되었다.
Object::Object(Object const&) [base object constructor]

주석 2 : 이동 생성자가 호출되었다.
Object::Object(Object&&) [base object constructor]

 

사용자가 정의하지 않은 특별 멤버함수가 잘 호출되고 있음을 확인할 수 있다.

 

✔ 규칙 2

✔ 복사계열 함수를 사용자가 정의하면, 컴파일러는 이동계열 함수를 제공하지 않는다.

  • 복사계열 함수 : 복사 생성자, 복사 대입연산자
  • 이동계열 함수 : 이동 생성자, 이동 대입연산자

 

아래 예제로 확인해 보자.

example
#include <iostream>
struct Object
{
    std::string name;
    int data;

    //기본 생성자
    Object(const std::string& name, int data) : name(name), data(data) {}

    // 사용자가 만든 복사 생성자
    Object(const Object& other) : name(other.name), data(other.data)
    {
        std::cout << "copy ctor\n";
    }
};
int main()
{
    Object obj1("obj1", 10);
    Object obj2("obj2", 10);
    Object obj3 = obj1;                   //1. 
    Object obj4 = std::move(obj2);        //2.
    std::cout << obj1.name << std::endl;
    std::cout << obj2.name << std::endl;  //3.
}
result
copy ctor
copy ctor
obj1
obj2

주석 1 : 사용자가 만든 복사생성자 호출 (copy ctor 로그로 확인)
주석 2 : 사용자가 만든 복사생성자 호출 : 정의된 이동 생성자가 없어서 아래처럼 복사 생성자가 호출이 되었다.
Object::Object(Object const&) [complete object constructor]

 

내부동작을 조금 더 자세히 정리하면

  • std::move(obj2) 코드는
  • static_cast<Object&&>(obj2)으로 obj2를 r-value로 변환하고,
  • 컴파일러는 이동 생성자 Object(Object&&)를 우선 찾았는데 없어서, 복사 생성자 Object(const Object&)를 호출했다.
💡 무조건 '복사생성자'가 대체될 수 있는 것이 아니라 파라미터 타입이 const&로 r-value를 받을 수 있었기에 에러 없이 호출된 것이다. (ex Object& 였다면 에러발생)
  • 주석 3에 obj2의 이름이 그대로 출력된 결과에서도 알 수 있듯이 obj2가 obj4로 소유권이 이동이 되지 않았다.

 

✔ 이 예제는 에러 없이 동작은 했지만 결과론적으론 의도대로 동작이 안된 사례이다.

 

이 처럼 의도대로 동작하지 않는 문제를 해결하려면 어떻게 해야 할까? 🤔

✔ 이럴 때는 default 키워드를 사용하여 정의를 해주면 된다.

Object(Object&&) = default;                 // 이동 생성자
Object& operator=(const Object&) = default; // 복사 대입 연산자
Object& operator=(Object&&) = default;      // 이동 대입연산자
별도로 정의해서 사용해야 할 때는 이런 특성을 고려하여 추가 작업을 꼭 해주어야 한다.
📜 일반적으로는 이동/복사 계열 함수를 동시에 제공하는 것이 관례이다.

 

✔ 규칙 3

이 번에는 반대로
이동계열 함수를 정의한 경우, 컴파일러는 복사계열 함수를 제공하지 않는다.

 

⚠️ 그런데 복사계열 함수는 이동계열 함수가 없어도 에러 없이 동작한다. (의도대로 동작하진 않겠지만) 하지만 복사가 필요할 때는 이동을 대신해서 사용할 수가 없다.

 

아래 예제에서 그 내용을 확인해 보자.

Example
#include <string>
#include <iostream>
struct Object
{
    std::string name;
    int data;
    //기본 생성자
    Object(const std::string& name, int data) : name(name), data(data) {}

    // 이동 생성자
    Object(Object&& other) noexcept
    : name(std::move(other.name)), data(std::move(other.data))
    {
        std::cout << "move obj : " << name << std::endl;
    }
};
int main()
{
    Object obj1("obj1", 10);
    Object obj2("obj2", 10);
    Object obj3 = obj1;             //1.
    Object obj4 = std::move(obj2);  //2.
    std::cout << obj1.name << std::endl;
    std::cout << obj2.name << std::endl;
}

주석 1 : error
obj1 이 l-value 이기에 이동 생성자로 obj3로 이동시킬 수 없다.
그런데 재미있는 건 에러의 원인인데 로그를 보면 '이동시킬 수 없다'가 아닌 복사 생성자가 삭제되어 있다고 한다.

error: use of deleted function 'Object::Object(const Object&)'
   28 |         Object obj3 = obj1;   //1.

 

잉❓ 난 삭제 한 적이 없는데? 🤔 위 예제를 컴파일러가 해석한 코드로 보면 다음과 같다.

 

컴파일러가 해석한 코드

📌 사용자가 이동계열을 정의한 경우
'복사계열 함수를 제공하지 않는다.'가 아니라 복사 계열함수를 삭제한다.

 

그래서 이런 에러를 띄우게 된 것이다.

💬 왜 이럴까를 생각해 보면 사용자가 이동계열만 정의한 의도는 '복사하지 않겠다'이기에 복사 생성자를 삭제함으로써 암묵적으로 복사되지 않게 하기 위한 컴파일러의 조치로 보인다.

 

그럼 컴파일러가 복사 계열을 삭제하지 않게 하려면 어떻게 해야 할까?🤔

✔ 아래처럼 default로 컴파일러가 생성하게 요청하거나, 사용자가 정의하면 된다.

Object(const Object &) = default; 

 


 

✍🏼 위 규칙을 통해 우리는 아래와 같은 내용으로 정리할 수 있다.

  1. 기본적으로 복사 계열과 이동 계열 함수를 만들지 않는다.
  2. 혹시나 4개 중 1개라도 만드는 경우는 나머지 3개를 모두 구현하거나 '=default'로 요청한다.
💬 컴파일러의 특별멤버함수 생성 규칙을 이해하고 의도된 프로그래밍을 하길 바란다.

 

참고


컴파일러가 어떻게 코드를 해석하는지 보여주는 사이트 : https://cppinsights.io

C++ 핵심 가이드라인 한글화 프로젝트 링크

C++ Core Guidelines 원문 링크

 

728x90