💬 최근 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가지인데, 주석 하나하나가 어떤 특별멤버함수 인지를 알아보면 아래와 같다.
- 디폴트 생성자
- 소멸자
- 복사생성자
- (복사) 대입연산자
- 이동 생성자
- 이동 대입연산자
📜 다만, 컴파일러가 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.
잉❓ 난 삭제 한 적이 없는데? 🤔 위 예제를 컴파일러가 해석한 코드로 보면 다음과 같다.

- 위 예제에 주석 1의 라인을 삭제하고 컴파일러가 어떻게 코드를 해석하는지 보여주는 사이트에서 직접 확인해 봐도 좋다.
📌 사용자가 이동계열을 정의한 경우
'복사계열 함수를 제공하지 않는다.'가 아니라 복사 계열함수를 삭제한다.
그래서 이런 에러를 띄우게 된 것이다.
💬 왜 이럴까를 생각해 보면 사용자가 이동계열만 정의한 의도는 '복사하지 않겠다'이기에 복사 생성자를 삭제함으로써 암묵적으로 복사되지 않게 하기 위한 컴파일러의 조치로 보인다.
그럼 컴파일러가 복사 계열을 삭제하지 않게 하려면 어떻게 해야 할까?🤔
✔ 아래처럼 default로 컴파일러가 생성하게 요청하거나, 사용자가 정의하면 된다.
Object(const Object &) = default;
✍🏼 위 규칙을 통해 우리는 아래와 같은 내용으로 정리할 수 있다.
- 기본적으로 복사 계열과 이동 계열 함수를 만들지 않는다.
- 혹시나 4개 중 1개라도 만드는 경우는 나머지 3개를 모두 구현하거나 '=default'로 요청한다.
💬 컴파일러의 특별멤버함수 생성 규칙을 이해하고 의도된 프로그래밍을 하길 바란다.
참고
컴파일러가 어떻게 코드를 해석하는지 보여주는 사이트 : https://cppinsights.io
C++ 핵심 가이드라인 한글화 프로젝트 링크
C++ Core Guidelines 원문 링크

'L C++' 카테고리의 다른 글
직접선언! 낄낄빠빠하자. (0) | 2024.09.09 |
---|---|
trivial 뜻 : 하찮은, 그렇지만 하찮지 않은 trivial‼ (0) | 2024.08.25 |
'0' 대신 'nullptr'를 써야 하는 이유 (0) | 2024.08.04 |
new/delete 동작 원리 이해하기 (4) | 2024.07.22 |
내가 설계한 객체에 유효성 기능 지원하기 (2) | 2024.06.09 |