C++ 특수 멤버 함수와 자동 선언 규칙
C++ 특수 멤버 함수가 언제 선언되고 삭제되는지, = default와 = delete, Rule of 0/3/5에 대해 정리했습니다.
개요
특수 멤버 함수(special member functions)는 프로그램이 직접 선언하지 않아도 컴파일러가 대신 선언할 수 있는 멤버 함수이다. 총 6개로 기본 생성자, 복사 생성자, 이동 생성자, 복사 대입 연산자, 이동 대입 연산자, 소멸자가 있다.
| 함수 | 선언 형태 | 호출되는 때 |
|---|---|---|
| 기본 생성자 | T() | 인자 없이 객체를 만들 때 |
| 복사 생성자 | T(const T&) | 같은 타입의 lvalue로 객체를 만들 때 |
| 이동 생성자 | T(T&&) | 같은 타입의 rvalue로 객체를 만들 때 |
| 복사 대입 연산자 | T& operator=(const T&) | 이미 있는 객체에 lvalue를 대입할 때 |
| 이동 대입 연산자 | T& operator=(T&&) | 이미 있는 객체에 rvalue를 대입할 때 |
| 소멸자 | ~T() | 객체의 수명이 끝날 때 |
이동 생성자와 이동 대입 연산자는 C++11에서 이동 의미론과 함께 추가되었다. 그전에는 네 가지였다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <string>
#include <utility>
struct Player
{
std::string name;
int score = 0;
};
int main()
{
Player a; // 기본 생성자
Player b = a; // 복사 생성자
Player c = std::move(a); // 이동 생성자
b = c; // 복사 대입 연산자
b = std::move(c); // 이동 대입 연산자
} // 소멸자 (c, b, a 순서)
Player에는 멤버 변수만 있지만 여섯 가지 연산이 모두 된다. 컴파일러가 여섯 함수를 모두 선언하고, 그중 실제로 쓰인 함수를 정의했기 때문이다. 이렇게 컴파일러가 선언한 함수는 public이고 inline이다.
컴파일러가 함수를 선언하는 것과 정의하는 것은 따로 일어난다. 선언은 클래스 정의가 끝나는 }에서 이루어지고, 정의는 그 함수가 실제로 쓰일 때 이루어진다.
그리고 선언 여부는 클래스에 무엇을 직접 선언했는지에 따라 달라진다. 소멸자 하나만 직접 선언해도 이동 생성자와 이동 대입 연산자는 선언되지 않는다. 선언은 되었지만, 멤버 때문에 쓸 수 없게 삭제되는 경우도 있다. 그래서 “컴파일러가 알아서 만들어 준다”는 말이 항상 맞지는 않다.
컴파일러가 만든 함수가 하는 일
컴파일러가 정의한 특수 멤버 함수는 기반 클래스와 멤버 각각에 같은 연산을 차례로 적용한다. 복사 생성자는 멤버를 하나씩 복사하고, 소멸자는 멤버를 하나씩 소멸시킨다. 이것을 멤버별(memberwise) 동작이라고 한다.
개요의 Player를 예로 들면, 컴파일러가 만든 여섯 함수는 아래처럼 직접 쓴 코드와 같은 일을 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct Player
{
std::string name;
int score = 0;
// 컴파일러가 만든 함수와 같은 동작을 직접 쓴 것
Player() {}
Player(const Player& o) : name(o.name), score(o.score) {}
Player(Player&& o) noexcept : name(std::move(o.name)), score(o.score) {}
Player& operator=(const Player& o)
{
name = o.name;
score = o.score;
return *this;
}
Player& operator=(Player&& o) noexcept
{
name = std::move(o.name);
score = o.score;
return *this;
}
~Player() {}
};
순서
멤버별 동작은 정해진 순서를 따른다.
| 함수 | 순서 |
|---|---|
| 생성자 (기본, 복사, 이동) | 기반 클래스 → 멤버 (선언 순서) |
| 대입 연산자 (복사, 이동) | 기반 클래스 → 멤버 (선언 순서) |
| 소멸자 | 생성의 역순: 멤버 (선언의 역순) → 기반 클래스 |
멤버의 순서는 클래스에 선언된 순서이다. 생성자의 초기화 목록에 적은 순서는 상관없다. 기반 클래스가 여럿이면 상속 목록에 적은 순서를 따른다.
복사와 이동
복사 생성자는 각 멤버를 원본의 같은 멤버로 직접 초기화한다. 이동 생성자는 원본을 rvalue로 보고 같은 일을 한다. 그래서 std::string처럼 이동 생성자가 있는 멤버는 이동되고, int 같은 기본 타입은 이동할 것이 없으니 복사된다.
1
2
3
4
5
6
7
Player a;
a.name = "Langerak";
a.score = 10;
Player b = std::move(a);
// b.name == "Langerak", b.score == 10
// a.name은 유효하지만 지정되지 않은 상태, a.score는 그대로 10
복사 생성자는 멤버의 기본값(int score = 0;의 = 0)을 쓰지 않는다. 원본의 값으로 초기화하기 때문이다.
멤버별 복사는 멤버의 값만 복사한다. 멤버가 원시 포인터라면, 가리키는 대상이 아니라 주소만 복사된다.
1
2
3
4
5
6
7
8
9
struct Buffer
{
int* data = new int[100];
~Buffer() { delete[] data; }
};
Buffer a;
Buffer b = a; // b.data와 a.data가 같은 메모리를 가리킨다
// 범위를 벗어나면 b와 a의 소멸자가 같은 메모리를 두 번 delete[] 한다
컴파일러는 data가 소유하는 메모리인지, 빌려 쓰는 주소인지 알 수 없다. 그래서 주소를 복사하는 것 외에는 할 수 있는 일이 없다. 이 문제가 Rule of 3가 나온 이유이다.
소멸자
컴파일러가 만든 소멸자의 본문은 비어 있다. 멤버를 소멸시키는 일은 본문이 아니라, 본문이 끝난 뒤 일어난다. 이건 소멸자를 직접 작성해도 똑같다. 직접 쓴 소멸자의 본문이 실행되고 나면, 멤버와 기반 클래스의 소멸자가 역순으로 자동 호출된다.
1
2
3
4
5
struct Logger
{
std::string name;
~Logger() { std::cout << name; } // 본문이 먼저 실행되고, 그다음 name이 소멸한다
};
그래서 소멸자를 직접 작성하는 것은 멤버를 소멸시키기 위해서가 아니라, 멤버가 스스로 정리하지 못하는 것(delete할 원시 포인터, 닫아야 할 핸들 등)을 정리하기 위해서이다.
기본 생성자
컴파일러가 만든 기본 생성자는 초기화 목록이 없고 본문이 비어 있는 생성자 T() {}와 같은 초기화를 한다. 각 멤버는 다음과 같이 초기화된다.
- 기본값(
= 0,{})이 있는 멤버는 그 기본값으로 초기화된다. - 클래스 타입 멤버는 그 클래스의 기본 생성자로 초기화된다.
int, 포인터 같은 기본 타입 멤버는 초기화되지 않는다.
1
2
3
4
5
6
7
8
9
struct Stats
{
std::string name; // 빈 문자열
int hp; // 초기화되지 않는다
int mp = 100; // 100
};
Stats a; // a.hp는 정해지지 않은 값
Stats b{}; // b.hp는 0
Stats a;는 기본 생성자만 호출하므로 hp는 정해지지 않은 값을 갖는다. 이 값을 읽으면 C++23까지는 미정의 동작이다. 반면 Stats b{};처럼 빈 중괄호로 만들면, 직접 작성한 기본 생성자가 없는 클래스는 기본값이 없는 멤버가 0이 된다. 같은 클래스라도 객체를 만드는 문법에 따라 결과가 달라지는 것이다. 전역 변수와 static 변수는 예외로, 문법과 상관없이 먼저 0으로 다채워진다.
그래서 기본 타입 멤버에는 int hp = 0;처럼 기본값을 적어 두는 것이 안전하다.
noexcept
컴파일러가 만든 특수 멤버 함수는 안에서 호출하는 함수가 모두 noexcept이면 noexcept가 된다. Player의 이동 생성자는 std::string의 이동 생성자를 호출하고, 이건 noexcept이므로 Player의 이동 생성자도 noexcept이다. 이 성질 덕분에 std::vector<Player>는 재할당할 때 원소를 복사하지 않고 이동한다. 좀 더 자세한 내용은 C++ 참조자, 값 범주, 이동 의미론, 전달 참조에 썼다.
자동 선언 규칙
자동 선언 규칙은 클래스에 직접 선언한 특수 멤버 함수에 따라, 컴파일러가 나머지 특수 멤버 함수를 어떻게 처리할지 정하는 규칙이다. 컴파일러의 선택지는 세 가지이다.
참고로 = default는 함수를 선언하면서 본문은 컴파일러가 만드는 것과 똑같이 만들어 달라고 요청하는 문법이고, = delete는 그 함수를 쓸 수 없게 삭제하는 문법이다.
- 선언한다 (
= default로 선언한 것과 같다) - 삭제된 것으로 선언한다 (
= delete로 선언한 것과 같다) - 선언하지 않는다
여기서 “직접 선언했다(user-declared)”는 말은 함수를 정의했다는 뜻이 아니다. 선언만 해도 해당된다. ~T() = default;나 T(const T&) = delete;처럼 = default나 = delete로 선언해도 직접 선언한 것이다.
함수별 규칙
- 기본 생성자 : 생성자를 하나라도 직접 선언하면 선언되지 않는다. 복사 생성자나 이동 생성자도 생성자에 포함된다.
- 소멸자 : 직접 선언하지 않으면 항상 선언된다.
- 복사 생성자, 복사 대입 연산자 : 직접 선언하지 않으면 선언된다. 다만 이동 생성자나 이동 대입 연산자를 직접 선언했다면 삭제된 것으로 선언된다.
- 이동 생성자, 이동 대입 연산자 : 복사 생성자, 복사 대입 연산자, 소멸자, 그리고 다른 쪽 이동 연산을 하나도 직접 선언하지 않았을 때만 선언된다.
규칙을 직접 선언한 함수 기준으로 모으면 아래 표가 된다.
| 직접 선언한 것 | 기본 생성자 | 소멸자 | 복사 생성자 | 복사 대입 | 이동 생성자 | 이동 대입 |
|---|---|---|---|---|---|---|
| 없음 | 선언 | 선언 | 선언 | 선언 | 선언 | 선언 |
다른 생성자 (T(int) 등) | 선언 안 됨 | 선언 | 선언 | 선언 | 선언 | 선언 |
| 기본 생성자 | 직접 | 선언 | 선언 | 선언 | 선언 | 선언 |
| 소멸자 | 선언 | 직접 | 선언 (deprecated) | 선언 (deprecated) | 선언 안 됨 | 선언 안 됨 |
| 복사 생성자 | 선언 안 됨 | 선언 | 직접 | 선언 (deprecated) | 선언 안 됨 | 선언 안 됨 |
| 복사 대입 | 선언 | 선언 | 선언 (deprecated) | 직접 | 선언 안 됨 | 선언 안 됨 |
| 이동 생성자 | 선언 안 됨 | 선언 | 삭제 | 삭제 | 직접 | 선언 안 됨 |
| 이동 대입 | 선언 | 선언 | 삭제 | 삭제 | 선언 안 됨 | 직접 |
표에서 “선언”으로 적은 함수도 멤버 때문에 삭제될 수 있다. 예를 들어 const 멤버가 있으면 복사 대입 연산자가 삭제된다. 이 경우는 “선언되지 않음과 삭제됨”에서 더 자세히 공부해보자.
이동이 복사로 바뀌는 경우
표에서 가장 눈여겨볼 곳은 소멸자 행이다. 소멸자만 선언해도 이동 연산이 선언되지 않는다. 그러면 rvalue를 넘겨도 이동 생성자가 없으니, const T&를 받는 복사 생성자가 대신 호출된다. 컴파일 에러나 경고가 없이 이동이 조용히 복사로 바뀐다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <iostream>
#include <utility>
struct Tracer
{
Tracer() = default;
Tracer(const Tracer&) { std::cout << "copy\n"; }
Tracer(Tracer&&) noexcept { std::cout << "move\n"; }
Tracer& operator=(const Tracer&) = default;
Tracer& operator=(Tracer&&) = default;
};
// 아무것도 선언하지 않음
struct A
{
Tracer t;
};
// 소멸자를 직접 정의
struct B
{
Tracer t;
~B() {}
};
// 소멸자를 = default로 선언
struct C
{
Tracer t;
~C() = default;
};
int main()
{
A a1;
A a2 = std::move(a1); // move
B b1;
B b2 = std::move(b1); // copy
C c1;
C c2 = std::move(c1); // copy
}
C의 경우에 ~C() = default;는 컴파일러가 만드는 소멸자와 하는 일이 똑같지만, 직접 선언한 것이라 이동 연산이 사라진다.
이렇게 정한 이유
기본 생성자 : 생성자를 하나라도 직접 선언했다는 것은 “이 클래스는 이렇게 만들어야 한다”고 정했다는 뜻이다. 예를 들어 T(int)만 만들었는데 인자 없는 생성자가 자동으로 생긴다면, 그 의도를 무시하고 객체를 만들 길이 열린다.
이동 연산 : 소멸자나 복사 연산을 직접 선언했다는 것은, 그 클래스가 자원을 직접 관리한다는 신호이다. 이런 클래스에 멤버별 이동 연산을 자동으로 만들면 위험하다. 예를 들어 원시 포인터 멤버를 멤버별로 이동하면 주소만 복사되고 원본은 그대로 남아서, 두 객체가 같은 메모리를 해제한다. 게다가 C++11 이전에 작성된 클래스는 이동을 전혀 고려하지 않았다. 그래서 C++11은 이런 클래스에 이동 연산을 만들지 않기로 했다. 이동 연산이 없으면 rvalue도 C++03 시절처럼 복사되므로, 기존 코드의 동작이 바뀌지 않는다.
복사 연산의 삭제 : 이동 연산을 직접 선언한 클래스는 C++11 이후에 작성된 코드이다. 이동 연산을 직접 썼다면 자원을 직접 관리하고 있을 가능성이 높으니, 멤버별 복사를 자동으로 만들면 위험하다. 이런 클래스는 지켜야 할 옛 코드가 없으므로, 복사 연산을 아예 삭제한다.
deprecated인 복사 : 같은 논리라면 소멸자를 직접 선언한 클래스의 복사 연산도 만들지 않는 것이 맞다. 하지만 그렇게 하면 C++03 코드가 대량으로 컴파일되지 않는다. 그래서 C++11은 복사 연산은 계속 만들되, 이 동작을 deprecated로 지정했다. 지금도 동작하지만 기대지 말라는 뜻이고, 일부 컴파일러는 경고를 낸다.
선언되지 않음과 삭제됨
삭제된 함수(deleted function)는 선언은 되어 있지만 호출하면 컴파일 에러가 나는 함수이다. 선언되지 않은 함수는 아예 존재하지 않는 함수이다. 둘 다 호출할 수 없다는 점은 같지만, 결과는 다르다.
차이는 오버로드 해석에서 생긴다. 삭제된 함수는 선언되어 있으므로 오버로드 후보에 들어간다. 그래서 가장 잘 맞는 함수로 선택되면 그 자리에서 컴파일 에러가 난다. 반면 선언되지 않은 함수는 후보에 없으니, 다른 함수가 대신 선택된다.
이동 생성자로 보는 차이
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <utility>
// 복사 생성자만 선언해서 이동 생성자가 선언되지 않음
struct CopyOnly
{
CopyOnly() = default;
CopyOnly(const CopyOnly&) = default;
};
// 이동 생성자를 직접 삭제
struct NoMove
{
NoMove() = default;
NoMove(const NoMove&) = default;
NoMove(NoMove&&) = delete;
};
int main()
{
CopyOnly a;
CopyOnly b = std::move(a); // OK: 복사 생성자가 호출된다
NoMove c;
NoMove d = std::move(c); // 에러: 삭제된 이동 생성자가 선택된다
}
CopyOnly는 이동 생성자가 없다. 그래서 rvalue를 넘기면 const CopyOnly&를 받는 복사 생성자가 선택된다. 반면 NoMove에는 삭제된 이동 생성자가 있고, rvalue에는 NoMove&&가 const NoMove&보다 더 잘 맞는다. 그래서 삭제된 함수가 선택되고 에러가 난다.
따라서 “복사는 되고 이동은 안 되는 클래스”를 만들고 싶다면, 이동 생성자를 = delete로 막으면 안 된다. 복사 연산만 선언해서 이동 연산이 선언되지 않게 해야한다. = delete로 막으면 그 클래스는 rvalue로부터 아예 만들 수 없게 된다.
암묵적으로 삭제되는 경우
컴파일러는 자동 선언 규칙에 따라 함수를 선언한다. 그런데 멤버 때문에 멤버별 동작을 만들 수 없으면, 그 함수를 삭제된 것으로 정의한다. 선언은 되었지만 쓸 수 없는 상태가 되는 것이다. 어떤 함수가 삭제되는지는 그 함수가 멤버에게 무엇을 해야 하는지를 보면 알 수 있다.
기본 생성자는 모든 멤버를 인자 없이 만들어야 한다. 컴파일러가 만든 기본 생성자는 초기화 목록이 없고 본문이 비어 있는 생성자와 같기 때문이다. 각 멤버는 멤버 선언에 기본값이 있으면 그 값으로 초기화되고, 기본값이 없으면 인자 없이 초기화된다. 하지만 인자 없이는 만들 수 없는 멤버가 있으면 기본 생성자는 삭제된다. 예를 들어,
첫 번째, 참조자 멤버는 만들어지는 순간 무엇을 가리킬지 정해야 한다.
1
2
3
4
5
6
7
8
9
int g = 0;
struct A
{
int& r; // 무엇을 가리킬지 정해지지 않았다
};
A a; // 에러: 기본 생성자가 삭제됨
A b{g}; // OK: 중괄호로 r에 g를 넘겨 준다
두 번째, const 기본 타입 멤버는 나중에 값을 바꿀 수 없다. 그런데 인자 없이 만들면 초기화되지 않은 값을 갖는다. 초기화되지 않은 채로 영원히 바꿀 수도 없는 값이 되는거다.
1
2
3
4
5
6
struct B
{
const int id; // 초기값이 없다
};
B b; // 에러: 기본 생성자가 삭제됨
세 번째, 기본 생성자가 없는 클래스 타입 멤버는 인자 없이 만들 수 없다.
1
2
3
4
5
6
7
8
9
10
11
struct Weapon
{
Weapon(int damage) {} // 이 생성자 때문에 Weapon의 기본 생성자가 선언되지 않는다
};
struct Player
{
Weapon weapon; // 인자 없이 Weapon을 만들 수 없다
};
Player p; // 에러: Player의 기본 생성자가 삭제됨
세 경우 모두 인자 없이 만들 수 없다는 것이 문제다.
복사 대입 연산자와 이동 대입 연산자는 이미 있는 객체의 멤버에 값을 대입해야 한다. 그래서 대입할 수 없는 멤버가 있으면 삭제된다. const 멤버는 값을 바꿀 수 없고, 참조자 멤버는 다른 대상을 다시 가리킬 수 없다. 이 경우는 기본값이 있어도 삭제된다. 대입은 초기화가 아니기 때문이다.
복사 생성자와 복사 대입 연산자는 멤버를 하나씩 복사해야 한다. 그래서 복사할 수 없는 멤버가 있으면 삭제된다. 대표적인 예가 std::unique_ptr이다.
기반 클래스도 멤버와 똑같이 취급된다. 기반 클래스의 복사 생성자가 삭제되었거나 private이면, 파생 클래스의 복사 생성자도 삭제된다.
예외 : 컴파일러가 만든 이동 연산이 삭제되면 오버로드 후보에서 빠진다
위에서 삭제된 함수는 오버로드 후보에 들어간다고 했지만 예외가 하나 있다. 컴파일러가 만들거나 = default로 선언한 이동 연산이 삭제된 경우에는, 오버로드 후보에서 빠진다. 즉, 선언되지 않은 것처럼 취급된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
#include <utility>
// 복사는 되지만 이동은 직접 삭제한 타입
struct Legacy
{
Legacy() = default;
Legacy(const Legacy&) { std::cout << "copy\n"; }
Legacy(Legacy&&) = delete;
};
// 아무것도 선언하지 않음
struct Wrapper
{
Legacy l;
};
int main()
{
Wrapper a;
Wrapper b = std::move(a); // copy
}
Wrapper의 이동 생성자는 Legacy의 삭제된 이동 생성자를 불러야 하므로, 암묵적으로 삭제된다. 이 이동 생성자가 후보에 남아 있다면 에러가 났겠지만, 후보에서 빠지기 때문에 Wrapper의 복사 생성자가 선택되고, Legacy는 복사된다.
이 예외 덕분에 “이동할 수 없으면 복사한다”는 동작이 멤버를 거쳐서도 유지된다. 예외는 이동 연산에만, 그리고 컴파일러가 만들었거나 = default로 선언한 경우에만 적용된다. = delete로 선언한 함수는 그대로 후보에 남는다.
간단히 정리하면
- 이동 생성자가 선언되지 않았을 경우엔 오버로드 후보에 들어가지 않고 rvalue로 초기화하면 복사 생성자가 호출된다.
- 이동 생성자를
= delete로 직접 삭제한 경우엔 오버로드 후보에 들어가고 rvalue로 초기화하면 컴파일 에러가 난다. - 이동 생성자를 컴파일러가 만들거나
= default인데 삭제된 경우엔 오버로드 후보에 들어가지 않고 rvalue로 초기화하면 복사 생성자가 호출된다.
= default와 = delete
= default는 특수 멤버 함수를 선언하면서 정의는 컴파일러가 만드는 것과 똑같이 해 달라고 요청하는 문법이다. = delete는 함수를 삭제된 것으로 정의하는 문법이다. 둘 다 C++11에서 추가되었다.
= default
자동 선언 규칙 때문에 사라진 함수를 다시 선언할 때 = default를 쓴다.
첫 번째는 다른 생성자를 만들어서 사라진 기본 생성자를 되살리는 경우이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <string>
#include <utility>
struct Player
{
std::string name;
Player(std::string n) : name(std::move(n)) {} // 이 생성자 때문에 기본 생성자가 선언되지 않는다
Player() = default; // 다시 선언한다
};
int main()
{
Player a; // Player() = default가 없으면 에러
Player b("Kim");
}
두 번째는 소멸자를 선언해서 사라진 이동 연산을 되살리는 경우이다. 기반 클래스 포인터로 파생 클래스 객체를 지우려면 소멸자가 virtual이어야 한다. 그래서 가상 함수가 있는 기반 클래스는 보통 소멸자를 virtual로 선언하는데, 소멸자를 선언하는 순간 이동 연산이 생성되지 않는다. 그래서 나머지도 = default로 함께 선언한다.
1
2
3
4
5
6
7
8
9
10
struct Base
{
virtual ~Base() = default; // virtual로 만들기 위해 선언했다
Base() = default;
Base(const Base&) = default;
Base(Base&&) = default; // 선언하지 않으면 이동이 복사로 바뀐다
Base& operator=(const Base&) = default;
Base& operator=(Base&&) = default;
};
클래스 안의 = default와 클래스 밖의 = default
= default는 클래스 안의 첫 선언에 쓸 수도 있고, 클래스 밖의 정의에 쓸 수도 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 클래스 안에서 = default
struct In
{
int x;
In() = default;
};
// 클래스 밖에서 = default
struct Out
{
int x;
Out();
};
Out::Out() = default;
둘은 하는 일은 같아 보이지만, 표준은 둘을 다르게 취급한다. 클래스 안의 첫 선언에서 = default로 둔 함수는 사용자가 제공한 함수(user-provided)가 아니다. 반면 클래스 밖에서 = default로 정의한 함수는 사용자가 제공한 함수이다. 이 차이로 두 가지 결과가 달라진다.
첫 번째는 trivial 여부이다. trivial한 함수는 아무 일도 하지 않거나, 메모리를 그대로 복사하는 것과 같은 함수이다. 컴파일러가 만든 함수는 가상 함수가 없고, 기반 클래스와 멤버의 같은 함수가 모두 trivial하면 trivial이 된다. 하지만 사용자가 제공한 함수는 하는 일이 같아도 trivial이 아니다.
1
2
std::is_trivially_default_constructible_v<In> // true
std::is_trivially_default_constructible_v<Out> // false
두 번째는 빈 중괄호로 만들 때의 결과이다. “컴파일러가 만든 함수가 하는 일”에서 Stats b{}의 멤버가 0이 된 건, 기본 생성자가 사용자가 제공한 함수가 아니었기 때문이다. 기본 생성자가 사용자가 제공한 함수이면 0으로 채우는 단계가 없어진다.
1
2
In a{}; // a.x == 0
Out b{}; // b.x는 초기화되지 않은 값
Out의 기본 생성자는 하는 일이 In과 똑같다. 그런데도 정의를 클래스 밖에 두었다는 이유만으로, 같은 {}에서 멤버가 초기화되지 않는다.
그래서 특별한 이유가 없다면 = default는 클래스 안에 쓴다. 클래스 밖에 쓰는 것은 헤더에서 아직 완전하지 않은 타입을 std::unique_ptr로 들고 있어서, 소멸자 정의를 .cpp로 옮겨야 하는 경우 정도이다.
C++20부터는 하나 더 달라진다. 생성자를 하나라도 직접 선언한 클래스는, 그게 = default여도 집합체(aggregate)가 아니다. 그래서 In은 C++17까지는 In i{5};로 멤버를 바로 초기화할 수 있었지만, C++20부터는 컴파일 에러가 난다.
참고로 집합체는 생성자 없이 {1, 2}처럼 중괄호 안의 값으로 멤버를 선언 순서대로 바로 초기화할 수 있는 타입이다.
= delete
= delete는 특수 멤버 함수를 막을 때 가장 많이 쓴다. 복사되면 안 되는 클래스는 복사 연산을 삭제한다.
1
2
3
4
5
6
struct Texture
{
Texture() = default;
Texture(const Texture&) = delete;
Texture& operator=(const Texture&) = delete;
};
여기서 자동 선언 규칙이 그대로 적용된다. 이동 연산은 복사 연산, 소멸자, 다른 쪽 이동 연산을 하나도 직접 선언하지 않았을 때만 선언되고, = delete로 선언한 것도 직접 선언한 것이다. Texture는 복사 연산을 = delete로 선언했으므로 이동 연산이 선언되지 않는다. 그래서 Texture는 복사도 이동도 안 되는 클래스가 된다. 이동을 허용하고 싶으면 이동 연산을 = default로 함께 선언해야 한다.
1
2
3
4
5
6
7
8
struct Texture
{
Texture() = default;
Texture(const Texture&) = delete;
Texture& operator=(const Texture&) = delete;
Texture(Texture&&) = default;
Texture& operator=(Texture&&) = default;
};
C++11 이전에는 복사 연산을 private으로 선언만 하고 정의하지 않는 방법을 썼다. 하지만 이 방법은 클래스 자신의 멤버 함수나 friend가 복사하면, 컴파일은 되고 링크 단계에서야 에러가 난다. = delete는 누가 부르든 컴파일 단계에서 에러가 나고, 에러 메세지도 “삭제된 함수”라고 명확하게 나온다.
= delete는 특수 멤버 함수가 아닌 일반 함수에도 쓸 수 있다. 원하지 않는 암묵적 변환을 막는 데 쓴다. 삭제된 함수도 오버로드 후보에 들어간다는 성질이다.
1
2
3
4
5
6
7
8
void SetHp(int hp) {}
void SetHp(double) = delete;
int main()
{
SetHp(10); // OK
SetHp(3.5); // 에러: double을 int로 바꿔서 받는 것을 막는다
}
= delete는 반드시 함수의 첫 선언에 써야 한다. 이미 선언한 함수를 나중에 삭제할 수는 없다.
Rule of 0, 3, 5
Rule of 0, 3, 5는 특수 멤버 함수 중 무엇을 직접 작성할지 정하는 가이드라인이다. 대상은 기본 생성자를 뺀 다섯 함수로, 소멸자, 복사 생성자, 복사 대입 연산자, 이동 생성자, 이동 대입 연산자이다. 표준에 적힌 규칙은 아니다. 앞에서 본 자동 선언 규칙과 멤버별 동작에서 따라 나온 결론이다.
Rule of 3
소멸자, 복사 생성자, 복사 대입 연산자 중 하나를 직접 정의해야 한다면, 셋 다 정의한다.
소멸자를 직접 정의했다는 것은, 그 클래스가 멤버별 동작으로는 정리할 수 없는 자원을 갖고 있다는 뜻이다. 그런데 소멸자를 직접 선언해도 복사 연산은 여전히 선언된다. 그래서 복사와 이동의 Buffer처럼 포인터의 주소만 복사되고, 두 객체가 같은 메모리를 두 번 해제한다. 자원을 멤버별로 정리할 수 없다면 멤버별로 복사해서도 안 되므로, 셋을 함께 정의한다.
Rule of 5
C++11에서 이동 연산이 추가되면서 Rule of 3는 Rule of 5가 되었다. 다섯 개 중 하나를 직접 선언한다면, 다섯 개를 모두 선언한다. 여기서 선언에는 직접 정의하는 것뿐 아니라 = default나 = delete로 선언하는 것도 포함된다.
소멸자나 복사 연산을 직접 선언하면 이동 연산은 선언되지 않는다. 그러면 rvalue를 넘겨도 복사가 일어나 동작은 맞지만 느려진다. 복사를 = delete로 막은 클래스라면 결과가 더 나쁘다. rvalue로도 객체를 만들 수 없게 된다.
Rule of 0
자원 관리는 그 일만 하는 클래스에 맡기고, 나머지 클래스는 다섯 함수 중 아무것도 선언하지 않는다.
복사와 이동의 Buffer가 원시 포인터로 하던 일은 std::vector가 이미 하고 있다. std::vector를 멤버로 두면 다섯 함수를 하나도 쓰지 않아도 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <cstddef>
#include <vector>
class Buffer
{
public:
explicit Buffer(std::size_t count)
: data(count)
{
}
private:
std::vector<int> data;
};
컴파일러가 만든 다섯 함수는 멤버별로 동작하므로 std::vector의 복사, 이동, 소멸자를 그대로 호출한다. 복사하면 내용이 복사되고, 이동하면 내용을 넘겨받고, 소멸하면 메모리가 해제된다.
Rule of 0을 기본으로 하는 이유는 세 가지이다.
- 직접 쓴 코드는 틀릴 수 있다. 자기 대입, 예외가 났을 때 객체의 상태, 이동한 뒤 원본을 비우는 일을 모두 직접 챙겨야 한다.
- 멤버를 추가해도 고칠 곳이 없다. 복사 생성자를 직접 썼다면 멤버를 추가할 때마다 복사 연산도 고쳐야 한다. 빠뜨려도 컴파일 에러가 나지 않는다.
- noexcept와 trivial 여부가 유지된다. 컴파일러가 만든 함수는 멤버에 따라 noexcept와 trivial이 정해진다. 직접 정의하면 noexcept는 직접 붙여야 하고, trivial은 되지 않는다.
여기서 “선언하지 않는다”는 말 그대로 선언하지 않는다는 뜻이다. 정의만 안 하는 것으로는 부족하다. ~T() = default; 한 줄만 써도 이동 연산이 선언되지 않는다.
Rule of 0와 Rule of 5는 적용되는 클래스가 다르다. std::vector, std::unique_ptr처럼 자원 하나만 관리하는 클래스는 Rule of 5로 작성한다. 그것을 멤버로 쓰는 나머지 클래스는 Rule of 0으로 작성한다.
예외는 기반 클래스이다. 기반 클래스 포인터로 파생 클래스 객체를 지우려면를 소멸자를 가상으로 선언해야 하고, 그러면 이동 연산이 선언되지 않는다. 그래서 다섯 함수를 모두 = default로 선언한다.