상위 문서: C++/문법
프로그래밍 언어 문법 | ||
{{{#!wiki style="margin: -16px -11px; word-break: keep-all" | <colbgcolor=#0095c7><colcolor=#fff,#000> 언어 문법 | C(포인터 · 구조체) · C++(자료형 · 클래스 · 이름공간 · 상수 표현식 · 특성) · C# · Java · Python · Kotlin · MATLAB · SQL · PHP · JavaScript · Haskell |
마크업 문법 | HTML · CSS | |
개념과 용어 | 함수 · 인라인 함수 · 고차 함수 · 람다식 · 리터럴 · size_t · 상속 · 예외 · 조건문 · 참조에 의한 호출 · eval | |
기타 | == · === · deprecated · NaN · null · undefined · 모나드 · 배커스-나우르 표기법 | |
프로그래밍 언어 예제 · 목록 · 분류 | }}} |
1. 개요2. 캡슐화3. 생성자4. 소멸자5. 멤버 한정자6. 친구 함수7. 친구 클래스8. 연산자 오버로딩9. 변환 함수
9.1. explicit
10. 특수 멤버 함수10.1. 복사와 이동10.2. 복사 생성자
11. delete12. default13. 클래스 활용 예제14. 상속15. 적법함16. 자명함10.2.1. 암시적 복사 생성자
10.3. 복사 대입 연산자10.3.1. 암시적 복사 대입 연산자
10.4. 이동 생성자10.4.1. 암시적 이동 생성자
10.5. 이동 대입 연산자10.5.1. 암시적 이동 대입 연산자
10.6. 등호 연산자10.7. 3방향 비교 연산자16.1. 자명한 기본 생성자16.2. 자명한 소멸자16.3. 자명한 복사 생성자16.4. 자명한 복사 대입 연산자16.5. 자명한 이동 생성자16.6. 자명한 이동 대입 연산자16.7. 자명하게 복사 가능한 클래스16.8. 자명한 클래스
17. 리터럴 클래스18. 표준 규격 클래스19. 일렬 데이터 클래스1. 개요
class [[특성]] 식별자 { 접근성 지시자: 생성자 ( 매개변수) ;자료형 데이터 멤버 식별자; 반환 자료형 멤버 함수 식별자 ( 매개변수); using 자료형 별칭; }; |
struct
)로부터 내려왔지만 지금은 여러가지 독특한 요소를 많이 가지고 있다. 일단 클래스는 구조체처럼 내부에 변수와 함수를 저장할 수 있다. 그렇지만 이 정도만으로는 구조체와 하등 다를 게 없다. 그러면 무엇이 어떻게 다를까?C언어에서의 구조체는 그저 순수한 메모리 규격으로써 마치 JSON처럼 이름 붙인 자료형만을 나열한 꼴이었다. 그리고 구조체의 상호작용은 반드시 구조체 인스턴스와 별도의 함수가 필요했다. 특히 구조체 내부에 함수 인터페이스를 만들려면 별도로 구조체 바깥에 정의된 함수를 포인터의 형태로 가져와야 했다. 거기에 인터페이스 함수들은 구조체가 생성된 스코프(Scope)에서 구조체를 참조할 포인터도 필요했다. 링커 오류 때문에 함수의 정의를 별도의 소스 파일에 구현하는 것도 필수였다. 이렇게 가져온 함수가 어떻게 동작하는지 보여줄 방법도 부족했으며, 반드시 구조체와 잘 동작한다는 보장이 없었다. 마지막으로 만든 속성들을 숨기거나, 최종 사용자에게 필요한 정보만 보여줄 수가 없었다.
C++의 클래스는 생성자로 인스턴스가 생성되는 방식을 결정할 수 있고, 함수 인터페이스를 몸체(Class Body) 안으로 들여 간편한 조작이 가능해졌다. 클래스의 핵심 기능 중 하나는 내부 변수를 클래스 내부의 함수 안에서 언제든지 이용할 수 있다는 점이다. 그러나 이것만으로는 클래스가 구조체에서 발전하였다고 말하기는 힘들다. 다른 클래스로부터 속성을 받아오는 상속과 다형성은 코드의 재사용성을 향상시켰다. 연산자 오버로딩을 통해 비록 생소하지만, 어디에서나 일관된 코드 작성을 할 수 있다. 그리고 정적 속성과 템플릿은 클래스가 어떤 특징을 가지고 있는지 메타 데이터로 표현할 수 있도록 해줬으며, 클래스의 인스턴스를 만들 필요도 없게 해주었다. 마지막으로 소멸자를 통해 객체가 파괴되는 방식도 결정할 수 있다. C++의 가장 큰 특징 중 하나인 저수준의 메모리 제어도 가능한 점을 종합해보면, C++의 클래스는 단순히 자료를 읽고 쓰게 해주는 인터페이스가 아니라 정보가 어떻게 흘러가는지 정의한 규칙 모음이라고 말할 수 있다. 다른 언어의 정말 주체적인 클래스와는 조금 다르지만, 지향하는 바는 같다.
2. 캡슐화
#!syntax cpp
// 표준 라이브러의 동적 문자열 클래스 std::string 가져오기
import <string>;
// 클래스 `Squirrel`
class Squirrel
{
public:
// 자료형 멤버 `name_t` (자료형 별칭)
using name_t = std::string;
// 정적 데이터 멤버 `canFly`
static inline const bool canFly = false;
// 멤버 함수 `SetName`
void SetName(name_t name)
{
myName = name;
}
// 멤버 함수 `GetAcornsCount`
size_t GetAcornsCount()
{
return myAcornsCount;
}
// 멤버 함수 `GetWeight`
float GetWeight()
{
return myWeight;
}
// 정적 멤버 함수 `CanFly`
static bool CanFly()
{
return canFly;
}
// 자료형 멤버 `Nest` (내포 클래스)
class Nest {};
// 복합적 자료형의 데이터 멤버: std::string myName
name_t myName;
// 자료형 별칭의 데이터 멤버: size_t myAcornsCount;
size_t myAcornsCount;
// 원시 자료형의 데이터 멤버: float myWeight
float myWeight;
};
캡슐화 (Encapsulation)클래스는 속성(Property)이라고 칭하는 여러가지 정보를 담을 수 있다. 그 동안 함수나 전역 스코프에 변수, 함수를 만들었던 것 처럼 똑같이 하면 된다. 속성의 종류는 비정적 데이터 멤버, 비정적 멤버 함수, 멤버 자료형, 그리고 정적 데이터 멤버와 정적 멤버 함수 5가지가 있다.
데이터 멤버(Data Member)는 클래스 안에 정의하고 사용할 수 있는 변수를 이르는 용어다. 멤버 함수(Member Function)는 클래스 내부에 정의하고 사용할 수 있는 함수를 이르는 용어다. 자료형 멤버는 클래스 몸체 안에 포함된 또다른 자료형을 이르는 용어다. 자료형 멤버에는 두 종류가 있는데 첫번째는 내부의 구조체, 클래스를 부르는 내포 클래스(Nested Struct/Class), 그리고 내부의 결합체(Union)를 부르는 내포 결합체(Nested Union)가 있다. 두번째는
typedef
/using
으로 선언한 자료형 별칭(Type Aliases)이 있다. 정적 멤버(Static Member)는 static
지시자가 붙어 클래스 내외부에서 언제든지 참조할 수 있는 멤버를 의미한다.한편 표준에서는 멤버를 붙여 칭하지만, 데이터 멤버는 다른 언어에서 흔히 부르는대로 필드(Field), 그리고 멤버 함수는 메서드(Method)라고 부르는 경우가 많다.
상기한 예제는 이름(
myName
), 도토리의 개수(myAcornsCount
), 무게(myWeight
) 세가지의 비정적 데이터 멤버와, 정적 데이터 멤버로 비행 가능 여부(canFly
), 멤버 함수로는 이름을 정하는 SetName
, 도토리의 개수를 반환하는 GetAcornsCount
, GetWeight
, 정적 멤버 함수 CanFly
, 둥지 클래스 Nest
, 그리고 이름 자료형을 지칭하는 자료형 별칭 name_t
까지 열개의 속성을 가진 다람쥐 클래스다.2.1. 정보 은닉
- <C++ 예제 보기>
#!syntax cpp import <string>; class Squirrel { public: // public 자료형 멤버 `name_t` using name_t = std::string; // public 데이터 멤버 `canFly` static inline const bool canFly = false; // public 멤버 함수 `SetName` void SetName(name_t name) { myName = name; } // public 멤버 함수 `GetAcornsCount` size_t GetAcornsCount() { return myAcornsCount; } // public 멤버 함수 `GetWeight` float GetWeight() { return myWeight; } // public 멤버 함수 `CanFly` static bool CanFly() { return canFly; } // public 멤버 `myName` name_t myName; private: // private 멤버 자료형 `MyNest` struct MyNest {}; // private 데이터 멤버 `myAcornsCount` size_t myAcornsCount; // private 데이터 멤버 `myWeight` float myWeight; };
클래스 몸체 안에 접근성 지시자와
:
를 붙여 하위 속성에 접근 권한을 일괄적으로 정할 수 있다. 이 문법은 태곳적 GOTO문에서 쓰인 블록 선언문과 비슷하다. 접근성 지시자는 private
, public
, protected
세 종류가 존재한다.private
[비공개]는 해당 클래스의 멤버에서만 접근할 수 있음을 뜻하고, public
[공개]은 해당 클래스의 외부에서도 접근할 수 있다는 것을 뜻한다. 기본적으로 클래스에는 private
권한이 적용되어 있다.[3]protected
[보호됨]는 자신 또는 해당 클래스를 상속받는 자식 클래스의 멤버에서만 접근할 수 있음을 뜻한다. 상속에 대한 건 하단의 상속 문단에서 설명한다.2.2. 실체화
- <C++ 예제 보기>
#!syntax cpp import <string>; class Squirrel { ... }; // 생략 int main() { // (1) // 오류! Squirrel.myName = "람쥐"; // (2) // 오류! size_t my_acorns_cnt = Squirrel.GetAcornsCount(); // (3) // 올바른 사용법 Squirrel squirrel; // 비정적 데이터 멤버 std::string squirrel_name1 = squirrel.myName; // (4) // 비정적 멤버 함수 float squirrel_weight = squirrel.GetWeight(); // (5) // 정적 데이터 멤버 // squirrel_canfly1 == false bool squirrel_canfly1 = Squirrel::canFly; // 또는 // squirrel_canfly2 == false bool squirrel_canfly2 = squirrel.canFly; // (6) // 정적 멤버 함수 // squirrel_canfly3 == false bool squirrel_canfly3 = Squirrel::CanFly(); // 또는 // squirrel_canfly4 == false bool squirrel_canfly4 = squirrel.CanFly(); // (7) // 자료형 멤버 (자료형 별칭) Squirrel::name_t squirrel_name2 = squirrel.myName; }
실제로 다람쥐 클래스를 사용하려고 했더니
Squirrel
이름으로 접근하려고 하면 컴파일 오류가 발생한다. 잘못된 멤버 접근 혹은 클래스의 인스턴스가 아니라면서 말이다. 이는 클래스의 (비정적) 속성들은 클래스의 정의 자체에 저장된 게 아니기 때문이다. 클래스는 사용자가 여러가지 정보를 한데 모아 재활용하기 위해 만들어진 자료형이다. 다시 말하면 클래스는 정보가 어떻게 구성돼 있는지를 나타내는 규격일 뿐으로, 실제로는 클래스의 복사본을 통해 속성을 사용해야만 한다. 클래스의 복사본은 사용자가 클래스의 정의와는 별개로 직접 만들어야 한다. 복사본은 우리가 지금까지 원시자료형을 사용했던 것처럼, 클래스의 이름을 자료형으로 사용하면 된다. 여기서 클래스는 사용자가 새로운 자료형을 창조한 것임을 알 수 있다. 클래스의 복사본은 대개 인스턴스 (Instance) 또는 직역해서 개체 혹은 실체라고 칭한다. 객체(Object)라고 불러도 문제는 없으나 Object는 보통 클래스의 이름에 붙는 경우가 많다.- 비정적 멤버: 클래스의 인스턴스를 만들어야 사용할 수 있다. 연산자 인스턴스의 오른쪽에
.
[구두점] 연산자를 붙여서 접근할 수 있다. 만약 인스턴스가 포인터인 경우->
[화살표] 연산자로 접근할 수 있다. 또한 인스턴스의 데이터 멤버를 참조형으로 가져오면 마찬가지로 참조 변수로도 접근할 수 있다. - 정적 멤버: 클래스의 인스턴스 없이 클래스 이름만으로 사용할 수 있다. 클래스 이름의 오른쪽에
::
[범위] 연산자를 붙이면 접근할 수 있다. - 자료형 멤버: 반드시 클래스 이름에
::
를 붙여 사용해야 한다.
2.2.1. 기본 생성
- <C++ 예제 보기>
#!syntax cpp import <string>; class Squirrel { ... }; // 생략 class Person { public: using name_t = std::string; name_t myName; unsigned int myAge; Squirrel myPet; protected: std::string myAddress; }; int main() { // (1) // 기본값을 할당하지 않는 기본 초기화 Person person0; // (2) // 자료형 명시와 소괄호를 사용한 기본 초기화 Person person1 = Person(); // (3) // 자료형 명시와 중괄호를 사용한 대입 초기화 Person person2 = Person{}; // (4) // auto와 중괄호를 사용한 대입 초기화 auto person3 = Person{}; // (5) // 소괄호를 사용한 기본 초기화 Person person4(); // (6) // 중괄호를 사용한 기본 초기화 // C++ 표준에서 권장하는 방식으로, 소괄호 초기화는 사용을 자제하도록 하고 있다 Person person5{}; // (7) // 기본값을 할당하지 않는 배열의 기본 초기화 Person people0[10]; // (8) // 중괄호를 사용한 배열의 기본 초기화 Person people1[10]{}; // (9) // 동적 메모리 할당에서의 기본값을 할당하지 않는 기본 초기화 Person* person6 = new Person; // (10) // 동적 메모리 할당에서의 소괄호를 사용한 기본 초기화 Person* person7 = new Person(); // (11) // 동적 메모리 할당에서의 중괄호를 사용한 기본 초기화 Person* person8 = new Person{}; // (12) // 동적 메모리 할당에서의 auto와 중괄호를 사용한 기본 초기화 // auto* 역시 사용할 수 있다 auto person9 = new Person{}; // (13) // 동적 할당된 배열의 기본값을 할당하지 않는 기본 초기화 Person* persons2 = new Person[10]; // (14) // 동적 할당된 배열의 중괄호를 사용한 기본 초기화 Person* people3 = new Person[10]{}; // (15) // 동적 할당된 배열의 auto와 중괄호를 사용한 기본 초기화 // auto* 역시 사용할 수 있다 auto people4 = new Person[10]{}; // (16) // new를 사용했다면 반드시 삭제해주자 delete person6; delete person7; delete person8; delete person9; delete[] people3; delete[] people4; }
기본 초기화를 이용해 원시자료형을 쓰는 것과 같은 방식으로 인스턴스를 생성할 수 있다. 기본 초기화를 하는 방법은 자료형 이름만 쓰는 방법, 소괄호, 중괄호를 붙이는 방법이 있다. 자료형 이름만 쓰는 경우, 원시자료형의 경우와 같이 값이 기본값이 들어가지 않고 메모리만 할당한다. 때문에 데이터 멤버에 쓸모없는 값이 들어있기 마련이라 별도의 멤버 초기화 방법을 쓰거나, 처음부터 괄호를 붙이는 게 좋다. 괄호를 붙여서 초기화하면 인스턴스의 비정적 데이터 멤버들이 기본값으로 초기화된다 [8]. 데이터 멤버 중에 인스턴스가 있으면 그 인스턴스도 같이 기본값으로 초기화된다.
주의할 점은 동적 할당한 인스턴스는 반드시 해제하는 걸 잊지 말아야 한다. C++의 메모리는 수동으로 관리해줘야 하는 불편함이 있다. 사실 굳이 C++에서 동적 할당을 할 필요는 없다. 인스턴스의 바이트 크기가 하드웨어 캐시의 크기 미만이라면 스택 안에서 해결할 수 있다. 좀 거대한 인스턴스라도 이동 연산을 활용하면 감당할 수 있다. 동적 할당이 필요하면 표준 라이브러리의 스마트 포인터를 사용하고,
new
, delete
는 존재는 한다 정도로 알아두는 게 훨씬 낫다.2.2.2. 암시적 생성
- <C++ 예제 보기>
#!syntax cpp import <utility>; import <string>; class Squirrel { ... }; // 생략 class Person { public: using name_t = std::string; static Person CreatePerson(const name_t& name, unsigned int age, Squirrel&& pet, const std::string& address) { // 암시적 생성 return Person{ name, age, std::move(pet), address }; } name_t myName; unsigned int myAge; Squirrel myPet; protected: std::string myAddress; }; int main() { // (1) // Squirrel squirrel{ "Lamarr", 3, 1.6f }; 는 접근 권한 때문에 실행할 수 없다 // Squirrel의 유일한 공용 멤버는 `myName` 뿐이다 // ()는 사용할 수는 있지만 권장되지 않는다 Squirrel headcrab("Lamarr"); // (2) // C++11 이전부터 가능했던 집결 초기화 방식 // `dictator`의 멤버 `myPet`은 기본값으로 초기화되므로 굳이 초기화하지 않아도 문제 없다. // 인스턴스 생성에 등호를 쓰는 건 권장되지 않는다 Person dictator = { "Breen", 52 }; // (3) // C++11부터 가능한 집결 초기화 방식 // Person phdoctor{ "Kleiner", 67, std::move(squirrel), "Massachusetts" }; 는 접근 권한 때문에 실행할 수 없다 // Person의 마지막 멤버 myAddress는 private다 Person phdoctor{ "Kleiner", 67, std::move(headcrab) }; // (4) Squirrel robot("Dog"); Person freeman = CreatePerson("Gorden", 27, std::move(robot), "Seattle"); }
집결 초기화는 C언어의 구조체로부터 내려오는 인스턴스 초기화 방법이다. 구조체/클래스/결합체의 비정적 데이터 멤버를 초기화할 수 있다. 이를 지원하는 구조체/클래스/결합체를 각각 Aggregate Struct/Class/Union이라 부른다. 인스턴스를 만들 때 비정적 데이터 멤버의 순서대로 값을 중괄호, 혹은 소괄호안에 전달하면, 전달된 대로 멤버에 초기값을 할당할 수 있다. 유의할 점은 현재 스코프에서 보이는 멤버만 초기화할 수 있다. 즉 클래스 외부에서는
private
, protected
멤버들에 접근할 수 없으므로 초기화도 할 수 없다. 이 경우 별도의 초기화 방법이 필요하다. 상단의 예제에서 클래스 Person
은 private
멤버 myAddress
의 초기화를 위해 정적 생성 함수를 제공하고 있다 [9].집결 초기화는 분명 사용하기 쉽지만 주의할 점이 있다. 일단 전술한 접근 권한 문제도 있고 외부에서 어떤 멤버가 초기화되는지 전혀 알 수 없다는 것이다. 집결 초기화는 해당 클래스를 작성한 사람이 아니면 활용이 거의 불가능하다. 집결 초기화를 잘 지원하려면
class
, struct
의 구분을 하는 걸 추천한다. C++에서 둘의 차이는 기본 권한이 private
인지 public
인지 여부 밖에 없지만 이게 객체 작성의 편의성과 생산성, 구현 시간, 그리고 미적으로도 영향을 준다. 많은 서적에서도 이 둘을 구분하는 걸 추천하는 편이다. 집결 초기화는 struct
에만 쓰도록 하고, class
에는 후술할 생성자 함수를 쓰는 게 좋다. 2.2.3. 명시적 생성
- <C++ 예제 보기>
#!syntax cpp class Squirrel { ... }; // 생략 class Person { ... }; // 생략 class House { public: using name_t = std::string; name_t myName; Person* myMembers; size_t membersCount; private: unsigned long long membersAsset; public: int myAge; unsigned long long myPrice; }; int main() { // (1) // 여전히 현재 스코프에서 보이는 멤버만 초기화할 수 있다 Squirrel squirrel { .myName = "Ramsy", }; // (2) // 마지막 항목의 `,`는 붙이지 않아도 상관없다 auto person = Person { .myName = "Sakura", .myAge = 25 }; // (3) House house { .myName = "Sweet home", .myMembers = new Person[5] { Person{ .myName="Daddy" }, Person{ .myName="Mommy" }, Person{ .myName="Me" }, Person{ .myName="Parents' son" }, Person{ .myName="My litte brother" } }, .membersCount = 5, .myAge = 7, .myPrice = 100'000'000 }; }
지정 초기화는 전술한 집결 초기화의 특수한 형태다. 중괄호 안에서
.
뒤에 데이터 멤버의 이름을 붙이면 해당 데이터 멤버를 초기화할 수 있다. C언어에서 최근에 지원하기 시작한 초기화 방법을 C++20부터 지원하고 있다. C언어의 구조체는 얼핏 JSON과 비슷한 형태지만 그간 쓰이던 집결 초기화는 불편함이 많았다. 대신 이름을 명시할 수 있도록 문법이 개선된 것이 지정 초기화다.2.2.4. 정적 멤버 초기화
- <C++ 예제 보기>
#!syntax cpp class Squid { public: static const bool canFly; }; const bool Squid::canFly = false; // C 방식의 정적 필드 정의 class Squirrel { public: static inline const bool canFly = false; // C++11의 inline을 사용한 정적 필드 정의 }; class FlyingSquirrel { public: constinit static /*inline*/ bool canFly = true; // C++17의 constinit을 사용한 컴파일 시간 정적 필드 정의 };
정적 멤버는 항상 초기화를 해주어야 한다 (정확히는 초기화할 수 있어야 한다). 정적 멤버는 프로그램 내내 주소가 변하지 않는 붙박이 객체다. 그리고 클래스의 내외부에서 언제 어디서나 접근할 수 있어야 한다. 그러므로 정적 데이터 멤버는 명백히 존재하는 값이어야 하며, 존재하는 값이란 반드시 어떤 값이 들어가야 한다는 뜻이다. 만약 초기화를 하지 못하면 컴파일 오류가 발생한다. 정적인 원시자료형 멤버는 문제가 없지만 클래스의 경우 기본값이 없으면 문제가 발생할 수 있다. 그리고
const
정적 멤버는 원시자료형이든 클래스이든 반드시 초기화해줘야 한다.2.3. this
- <C++ 예제 보기>
#!syntax cpp import <string>; class Squirrel { public: using name_t = std::string; void SetName(name_t Name) { // this->가 없어도 문제 없음 this->Name = Name; } name_t Name(name_t Name) { // 같은 이름인 멤버 함수와 데이터 멤버는 반드시 구분해줘야 함 (*) this->Name = Name; } void Name() { // (*) return this->Name; } size_t AcornsCount() { // (*) return this->AcornsCount; } float Weight() { // (*) return this->Weight; } name_t Name; private: size_t AcornsCount; float Weight; };
this
인스턴스 자기 자신의 주소를 의미하는 예약어다 [10]. 기본적으로 비정적 멤버에 접근할 때는 암시적으로
this->
가 사용된다. C++에서는 매개변수의 이름을 데이터 멤버의 경우와 동일하게 지정해도 혼선이 생기지 않으므로 대부분의 경우 쓸 필요없다. 하지만 멤버 함수와 데이터 멤버가 같은 이름을 가지고 있으면 구분을 위해 필요하다. 왜냐하면 멤버 함수의 이름 자체는 함수의 포인터이므로, 멤버 함수의 이름을 쓰면 포인터가 반환되기 때문이다.3. 생성자
class Class
{ public: Class (); Class ( Parameters... ); }; |
생성자는
클래스 이름([매개 변수])
형식으로 정의되는 특수 함수다 [11]. 인스턴스가 생성되면서 동시에 인스턴스의 초기화를 위하여 실행된다. 생성자는 자료형, 구조체와는 구분되는 특별한 기능으로써 데이터가 어떻게 생성 되는지에 관여한다. 일반적인 함수와 마찬가지로 각종 지시자를 사용할 수 있다. 한편 이중에 매개변수가 없는 생성자를 기본 생성자 (Default Constructor)라고 부른다. 상기한 기본 초기화는 기본 생성자를 사용한 초기화다. 집결 초기화와 지정 초기화는 어떤 형태로든 생성자가 있으면 사용할 수 없다. 3.1. 초기자
- <C++ 예제 보기>
#!syntax cpp import <utility>; import <string>; class Person { public: using name_t = std::string; // (1) // 모든 데이터 멤버를 초기화하는 생성자 Person(const name_t& name, unsigned int age, Squirrel&& pet, const std::string& address) : myName(name), myAge(age) , myPet(std::move(pet)) , myAddress(address) {} // (2) // 반드시 모든 멤버를 초기화할 필요는 없다 Person(const name_t& name, unsigned int age, Squirrel&& pet) : myName(name), myAge(age) , myPet(std::move(pet)) {} // (3) // 다른 생성자를 호출할 수 있다 // 그러나 이때 다른 멤버를 같이 초기화 할 수는 없다 Person(const name_t& name, unsigned int age) : Person(name, age, Squirrel{}) {} // 필드의 선언은 뒤쪽에 위치시킬 수 있다 name_t myName; unsigned int myAge; Squirrel myPet; protected: std::string myAddress; };
클래스 생성자의 매개변수 뒤쪽 닫는 괄호 뒤에
:
와 데이터 멤버의 이름, 값을 전달해서 명시적으로 멤버의 초기화를 수행할 수 있다. 인스턴스의 생성 순간에 명시한 데이터 멤버를 초기화하는 기능을 수행한다. 중요한 점은 필드에 대입 연산을 하는 게 아니라, 인스턴스와 함께 멤버가 생성된다는 것이다. 이와 같은 맥락으로 멤버 변수로써의 클래스 인스턴스의 초기화도 가능하다. 이니셜라이저는 다른 생성자를 중복으로 호출할 수 있다. 이니셜라이저를 잘 활용하면 객체 생성의 번거로움을 줄일 수 있다.상단의 예제에서는 생성자의 닫는 괄호 뒤에 오는
: myName(name), myAge(age), ...
가 이니셜라이저다. name_t myName{ name };
, unsigned int myAge{ age };
, Squirrel myPet{ std::move(pet) };
, std::string myAddress{ address };
가 실행된다.3.2. 표준 라이브러리: initializer_list
- <C++ 예제 보기>
#!syntax cpp import <initializer_list>; class Adder { public: Adder(std::initializer_list<int> list) { for (auto& value : list) { mySummary += value; } } // 오버플로우 방지 long long mySummary; }; class Multiplier { public: Multiplier(std::initializer_list<int> list) { for (auto& value : list) { mySummary *= value; } } // 오버플로우 방지 long long mySummary; }; int main() { // (1) Adder adder{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; // 결과는 45 const int summary_added = adder.mySummary; // (2) Multiplier multi{ 2, 4, 8, 16, 32 }; // 결과는 4096 const int summary_multiplied = multi.mySummary; }
std::initializer_list<T>
는 C++11부터 지원되는 가변 생성자를 지원하는 유틸리티 클래스다. std::initializer_list<T>
는 사용자가 만들 수 없고, 오직 함수에 중괄호와 다수의 인자를 전달했을 때에만 생성된다. 템플릿으로 제작되어 모든 자료형을 지원한다.헌데
std::initializer_list<T>
는 문제점이 있는데, 가변 인자를 지원하는 점은 좋으나 단일 자료형만 받을 수 있다는 약점이 있다. 그리고 암시적으로 형변환되는 자료형은 문제가 생길 수 있다. 만약 최적화를 위해 부동소수점, 문자열, 정수형을 받는 생성자들의 동작을 다르게 만든다고 해보자. 그런데 int
, float
은 32비트로서 서로 암시적으로 변환된다. 당연히 명시적으로 숫자에 .0f
따위를 붙이면 문제가 안된다고 생각할 수 있으나, 임의의 숫자를 받는 경우와 템플릿에서 사용할 때 문제가 된다. 두 숫자 자료형의 공통 자료형으로 전부 형변환되므로 std::initializer_list<float>
만 받아진다. 그리고 이 형변환 문제는 원시자료형만 해당되는 게 아니다.3.3. explicit
- <C++ 예제 보기>
#!syntax cpp import <initializer_list>; class Integer { public: Integer(int value) noexcept : myValue(value) {} int myValue; }; class Adder { public: Adder(std::initializer_list<int> list) { for (auto& value : list) { mySummary += value; } } Adder(std::initializer_list<Integer> list) { for (auto& value : list) { mySummary += value.myValue; } } long long mySummary; }; int main() { // (1) // 어떤 생성자 오버로딩을 선택하는가? Adder adder0{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; // (2) // 이 경우는? Adder adder1{ { 0 }, { 1 }, { 2 }, { 3 }, { 4 }, { 5 }, { 6 }, { 7 }, { 8 }, { 9 } }; // (3) // 이 경우에는 intializer_list<int>가 선택된다.`Integer` 클래스에 기본 생성자가 없으므로 Adder adder2{ {}, { 1 }, {}, { 3 }, {}, { 5 }, {}, { 7 }, {}, { 9 } }; // (4) // 이 경우에는 intializer_list<Integer>가 선택된 Adder adder3{ { 0 }, { 1 }, { 2 }, { 3 }, { 4 }, { 5 }, Integer{ 6 }, { 7 }, { 8 }, { 9 } }; }
explicit
은 사용자에게 클래스를 생성할 때, 반드시 클래스의 이름을 쓰도록 강제한다.지금까지 생성자를 설명했는데 생성자를 쓰는 방식에는 문제점이 있다. 생성자 예제를 보면
Person
클래스의 생성자에서 Squirrel&&
을 받는 경우를 볼 수 있다. 그 예제에서는 Squirrel{
}으로 다람쥐 인스턴스를 만들어서 전달했으나 사실 {
}만으로 인스턴스를 만들 수 있다. 이는 C++에 있는 문법적 문제 중 하나이다. 사용자가 지금 만들어지는 인스턴스가 어떤 클래스인지 바로 알 수 없고, 어떤 인자에 전달하는지 알아보기 힘들며, 마지막으로 기본 초기화를 할 때 사용자가 의도하지 않은 동작이 일어날 수 있다는 것이다. 사용자는 오로지 어떤 방식으로 인스턴스를 생성하는 방법을 강제하고자 하는데, 그걸 위해 생성자가 필요한 법이다. 그러나 생성자를 정의하더라도 실제로 인스턴스를 생성하는 장소에 가면 여전히 뭘 어떻게 생성하는지 알 수가 없다는 것이다. 물론 IDE 등 개발 도구의 발전으로 매개변수 정도는 보여주겠지만 무슨 자료형인지 알기는 아직 힘들다.- <C++ 예제 보기>
#!syntax cpp import <initializer_list>; class Integer { public: // explicit 사용 explicit Integer(int value) noexcept : myValue(value) {} int myValue; }; class Adder { public: // (1) // 문제 없음 Adder(std::initializer_list<int> list) { for (auto& value : list) { mySummary += value; } } // (2) // 문제 없음 Adder(std::initializer_list<DoubledInteger> list) { for (auto& value : list) { mySummary += value.myValue; } } long long mySummary; }; int main() { // (1) // intializer_list<int>가 선택된다 Adder adder0{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; // (2) // intializer_list<int>가 선택된다 Adder adder1{ { 0 }, { 1 }, { 2 }, { 3 }, { 4 }, { 5 }, { 6 }, { 7 }, { 8 }, { 9 } }; // (3) // intializer_list<int>가 선택된다 Adder adder2{ {}, { 1 }, {}, { 3 }, {}, { 5 }, {}, { 7 }, {}, { 9 } }; // (4) // 이제 intializer_list<Integer>가 선택되게 하려면 클래스명을 다 명시해야 한다 Adder adder3{ Integer{ 0 }, Integer{ 1 }, Integer{ 2 }, Integer{ 3 }, Integer{ 4 }, Integer{ 5 }, Integer{ 6 }, Integer{ 7 }, Integer{ 8 }, Integer{ 9 } }; }
explicit
을 쓰면 모호성을 제거하고 지금 어떤 생성자가 이용될지 구분할 수 있게 해준다.3.3.1. 설계 패턴: 함수 꼬리표 분배
- <C++ 예제 보기>
#!syntax cpp import <cstdint>; import <initializer_list>; struct TagInt16 { explicit TagInt16() noexcept {} }; struct TagInt32 { explicit TagInt32() noexcept {} }; struct TagInt64 { explicit TagInt64() noexcept {} }; struct TagReplace{ explicit TagReplace() noexcept {} }; struct TagAdd { explicit TagAdd() noexcept {} }; class Storage { public: // 매개변수의 이름이 없는 경우는 C++17부터 가능하다 // 이름없는 매개변수는 사용하지 않아도 경고가 발생하지 않는다. // C++14까지는 `_` 따위의 준 익명 변수명이 필요하다 Storage(TagInt16, std::int16_t value) noexcept : myValue1(value), myValueIndex(0) {} Storage(TagInt32, std::int32_t value) noexcept : myValue2(value), myValueIndex(1) {} Storage(TagInt64, std::int64_t value) noexcept : myValue3(value), myValueIndex(2) {} void Calculate(TagReplace, TagInt16, std::int16_t value) noexcept { myValue1 = value; myValueIndex = 0; } void Calculate(TagReplace, TagInt32, std::int32_t value) noexcept { myValue2 = value; myValueIndex = 1; } void Calculate(TagReplace, TagInt64, std::int64_t value) noexcept { myValue3 = value; myValueIndex = 2; } void Calculate(TagAdd, std::int16_t value) noexcept { if (0 == myValueIndex) { myValue1 += value; } else if (1 == myValueIndex) { myValue2 += value; } else if (2 == myValueIndex) { myValue3 += value; } } void Calculate(TagAdd, std::int32_t value) noexcept { if (0 == myValueIndex) { myValue1 += static_cast<std::int16_t>(value); } else if (1 == myValueIndex) { myValue2 += value; } else if (2 == myValueIndex) { myValue3 += value; } } void Calculate(TagAdd, std::int64_t value) noexcept { if (0 == myValueIndex) { myValue1 += static_cast<std::int16_t>(value); } else if (1 == myValueIndex) { myValue2 += static_cast<std::int32_t>(value); } else if (2 == myValueIndex) { myValue3 += value; } } // 익명 결합체 union { std::int16_t myValue1; std::int32_t myValue2; std::int64_t myValue3; }; int myValueIndex; };
하지만 아직도 모호성을 없애기는 부족하다. 원시 자료형에 적용되는 자동 형변환을 피할 수 없기 때문이다. 나중에 설명하겠지만
std::initializer_list<T>
과 다른 생성자를 구분할 수 없는 문제도 있다. 또한 가변인자 템플릿에서 임의의 자료형을 매개변수로 받으면 이 역시 다른 생성자와 구별할 수 없다. 이를 해결하기 위한 디자인 패턴이 꼬리표 분배다. 예제에서 {Tag...
로 시작하는 구조체들이 꼬리표(Tag) 구조체다. 이 구조체들은 그 자체로는 아무것도 하는 게 없고 오직 실행되는 함수 오버로딩을 구분하는 역할을 한다. 이 방법을 쓰면 구현부가 쪼개지는 불편함은 있으나 어차피 필요한 동작이라면 확실하게 눈에 보이게 드러낼 수 있다는 장점이 있다. C++17부터는 매개변수에 이름이 없어도 되므로 매우 편리하게 쓸 수 있다. 게다가 이름없는 매개변수는 성능에 영향도 없도록 최적화 되므로 더욱 좋다.4. 소멸자
class Class
{ public: ~Class (); }; |
소멸자는 ~클래스 이름()형식으로 정의되는 특수 함수다. 클래스 인스턴스의 파괴 시점에서 비정적 데이터 멤버의 메모리 반환을 위하여 실행되는 함수이다. C++17부터는 소멸자에
noexcept
를 사용할 수 있다. 여담으로
delete this;
를 실행하면 소멸자가 즉시 호출된다. 그런데 문맥의 진행은 계속되는데 필드의 메모리는 증발하므로, 이후 실행 경로에서 치명적인 메모리 오류를 낼 수 있으므로 가급적 하면 안된다.- <C++ 예제 보기>
#!syntax cpp class MyString { public: MyString(size_t length) : myStr(new const char[length]) {} // (1) // 소멸자 ~MyString() { delete[] myStr; } private: const char* myStr; };
~MyString()
가 소멸자이다. 생성자에서 동적 할당된 char
자료형인 myStr
를 소멸자 ~MyString()
에서 delete[]
를 통해 메모리를 수동으로 해제하고 있다. 클래스 내부에서 동적 할당 등을 통해 관리하는 리소스가 있다면 소멸자 내부에 이 리소스들에 대한 해제 코드를 작성 해줘야 한다. 만약 예외가 발생하면 일반적으로는 해당 위치에서 함수를 종료하고 문맥을 반환하게 되는데, 메모리 해제해 주는 코드가 이 뒤에 있다면 해당 코드가 실행되지 않아 얄짤없이 메모리 누수가 일어나게 된다. 하지만 소멸자는 예외로 인해 종료되는 상황에서도 반드시 호출되기 때문에 꼭 소멸자 내부에 메모리 해제 코드를 작성해주도록 하자.- <C++ 예제 보기>
#!syntax cpp class MyString17 { public: MyString17(std::string_view string) : myStr{ string.begin(), string.end() } // C++17에서는 std::string_view를 std::string에 직접 대입할 수가 없었다. : myStr(string) // C++23부터 std::string_view를 std::string에 대입할 수 있다. {} // (2) // 소멸자 // 사실 정의하지 않아도 `myStr: std::string`의 소멸자가 알아서 호출된다 ~MyString17() noexcept {} private: std::string myStr; };
std::string
은 내부에 동적 할당된 메모리를 들고 있지만 소멸자에서 알아서 해제하므로 사용자 단에선 신경쓰지 않아도 된다.5. 멤버 한정자
5.1. 데이터 멤버
- <C++ 예제 보기>
#!syntax cpp class Framework; class Scene; class SceneTimer; class GameObject { public: explicit GameObject(int id, Framework& global_system, Scene& scene, const SceneTimer& timer) : myId(id) , gameSystem(global_system), myScene(scene), sceneTimer(timer) {} const int myId; Framework& gameSystem; Scene& myScene; const SceneTimer& sceneTimer; };
const
, volatile
, &
, &&
를 사용할 수 있다. 한정자를 붙이면 사용자가 염두에 두어야 하는 것들이 있다. 먼저 const
는 일반적인 상수의 경우와 같이 비정적 멤버, 정적 멤버 두 경우 모두 초기화를 해줘야 한다. 그리고 &
와 &&
의 경우 인스턴스가 생성될 때 참조할 원본 변수를 반드시 인자로 전달해야 한다. 참조형 데이터 멤버를 쓸 때 조심해야 할 것은 참조 대상 소실(Dangling)이다. &
와 &&
는 하드 링크나 공유 포인터가 아니기 때문에 포인터와 마찬가지로 참조했던 객체가 사라질 수 있다. 가령 문맥을 벗어나서 참조할 지역 변수가 사라졌거나, 포인터에 *
연산자를 써서 얻었던 참조형이 delete
되어 사라진다거나 말이다. 그리고 &
와 &&
둘 다 참조형이라는 건 문제가 생길 여지가 다분하다. 다음과 같이 말이다.- <C++ 예제 보기>
#!syntax cpp import <utility>; enum class InternetProtocol { Unknown, IPv4, IPv6 }; class Socket { public: // 빈 `Socket` 인스턴스 Socket() {} bool IsAvailable() const noexcept; // `Socket`을 만드는 공장 함수 static Socket CreateSocket(int address_family, InternetProtocol protocol) { return Socket{ address_family, protocol }; } // `Socket`을 만드는 공장 함수 static bool TryCreateSocket(Socket& out, int address_family, InternetProtocol protocol) { if (auto result = Socket{ address_family, protocol }; result.IsAvailable()) { out = std::move(result); // 문제 발생! // `address_family`와 `protocol`은 lvalue이므로 `out`의 `myAddressFamily`, `myProtocol` 은 각각 이들을 가리킨다 return true; // 지금 이 순간, 지역 변수 `address_family`와 `protocol`이 소멸한다 } else { return false; } } private: Socket(int address_family, InternetProtocol protocol); // && 데이터 멤버 int&& myAddressFamily; InternetProtocol&& myProtocol; };
lvalue
에서 참조 대상 소실이 일어나는 건 그렇다 치고, rvalue
에서도 똑같은 문제가 발생한다. 초기화를 prvalue
가 아닌 glvalue
로 받으면 &
참조형처럼 원본 변수가 사용된다. 만약 초기화를 강제하겠다고 &&
를 쓰면 안된다. 값의 초기화는 어차피 복사 혹은 이동으로 하는데, 이 둘은 후술할 특수 멤버 함수로 제어할 수 있다. 복사는 금지시킬 수도 있고, 이동은 데이터 멤버 초기화할 때 생성자의 매개변수에서 바로 이동할 수 있으므로 의미가 없다. 실수가 나오기 쉬우므로 &&
는 조심해서 사용해야 한다.5.2. 멤버 함수
- <C++ 예제 보기>
#!syntax cpp import <utility>; import <string>; import <string_view>; enum class Gender { NonBinary = 0, Male, Female, Lesbian, Gay, Binary, TransgenderMF, TransgenderFM, }; class Person { public: using name_t = std::string; Person(const name_t& name, unsigned int age, Gender gender, Squirrel pet = {}) : myName(name), myAge(age), myGender(gender) , myPet(pet) , myAddress() {} Person(name_t&& name, unsigned int age, Gender gender, Squirrel pet = {}) /* noexcept */ : myName(std::move(name)), myAge(age), myGender(gender) , myPet(pet) , myAddress() {} Person(const name_t& name, unsigned int age, Gender gender, std::string_view address, Squirrel pet = {}) : myName(name), myAge(age), myGender(gender) , myPet(pet) , myAddress(address) {} Person(name_t&& name, unsigned int age, Gender gender, std::string_view address, Squirrel pet = {}) : myName(std::move(name)), myAge(age), myGender(gender) , myPet(pet) , myAddress(address) {} void BuyAPetIfHasNothing(Squirrel&& pet) { myPet = std::move(pet); } void SetGender(Gender gender) noexcept { myGender = gender; } // (1) // const 한정자를 사용하면, 인스턴스가 어떤 상태이든 항상 사용할 수 있다 unsigned int GetAge() const noexcept { return myAge; } // (2) // const 한정자가 없다면, 데이터 멤버의 참조 자료형은 비상수 `lvalue`다. Squirrel& GetPet() noexcept { return myPet; } // (3) // const 한정자가 있으면, 데이터 멤버의 참조 자료형은 상수 `lvalue`다. const Squirrel& GetPet() const noexcept { return myPet; } // (4) // C++11부터 가능한 참조 한정자 name_t& GetName() & noexcept { return myName; } // (5) // C++11부터 가능한 참조 한정자 const name_t& GetName() const& noexcept { return myName; } // (6) // C++11부터 가능한 참조 한정자 name_t&& GetName() && noexcept { // rvalue로 형변환하지 않으면 오류가 발생한다 return std::move(myName); } // (7) // C++11부터 가능한 참조 한정자 const name_t&& GetName() const&& noexcept { // rvalue로 형변환하지 않으면 오류가 발생한다 return std::move(myName); } // (8-a) // 복사 // const&와 &&를 붙이는 것만으로 대부분의 경우의 수가 해결된다 // const& 한정자는 &, const&&를 대신해서 실행될 수 있다. const Gender& GetGender() const& noexcept { return myGender; } // (8-b) // 이동 Gender&& GetGender() && noexcept { return std::move(myGender); } // (9) // 상수 lvalue 한정자는 비상수 lvalue 상태에서도 사용할 수 있다 // 그러나 rvalue 상태에서는 사용할 수 없다 const std::string& GetAddress() const& noexcept { return myAddress; } name_t myName; unsigned int myAge; Gender myGender; Squirrel myPet; protected: std::string myAddress; }; int main() { // (1) // person0은 비상수 lvalue: Person& // 현재 사용할 수 있는 멤버: BuyAPetIfHasNothing[non-const], SetGender[non-const], GetAge[const], GetPet[non-const, const], GetName[&, const&], GetGender[const&], GetAddress[const&] Person person0{ "Who", 20, Gender::TransgenderFM }; // (2) person0.GetAge(); // unsigned int person0.GetPet(); // Squirrel& person0.GetName(); // Person::name_t& (std::string&) person0.GetGender(); // const Gender& person0.GetAddress(); // const std::string& // (3) // std::move(person0)은 비상수 rvalue: Person&& // 현재 사용할 수 있는 멤버: BuyAPetIfHasNothing[non-const], SetGender[non-const], GetAge[const], GetPet[const], GetName[&&], GetGender[&&] std::move(person0); // (4) std::move(person0).GetAge(); // unsigned int std::move(person0).GetPet(); // Squirrel&, std::move(person0.GetPet())을 권장한다 std::move(person0).GetName(); // Person::name_t&& (std::string&&) std::move(person0).GetGender(); // Gender&& // (5) // person1은 상수 lvalue: const Person& // 현재 사용할 수 있는 멤버: GetAge[const], GetPet[const], GetName[const&], GetGender[const&], GetAddress[const&] const Person person1{ "Three", 20, Gender::TransgenderMF }; // (6) person0.GetAge(); // unsigned int person0.GetPet(); // const Squirrel& person0.GetName(); // const Person::name_t& (const std::string&) person0.GetGender(); // const Gender& person0.GetAddress(); // const std::string& // (7) // std::move(person1)은 상수 rvalue: const Person&& // 현재 사용할 수 있는 멤버: GetAge[const], GetPet[const], GetName[const&&], GetGender[const&] std::move(person1); // (8) std::move(person1).GetAge(); // unsigned int std::move(person1).GetPet(); // const Squirrel& std::move(person1).GetName(); // const Person::name_t&& (const std::string&&) std::move(person1).GetGender(); // const Gender& }
const
, volatile
등 자료형 한정자를 붙일 수 있다. 이 한정자는 바로 인스턴스 자기 자신한테 붙는 한정자로서, 그리고 데이터 멤버의 한정자를 결정한다. 예를 들어 const
가 붙은 멤버 함수에서는 this
는 const Class*
가 되며, 자신의 참조형은 const Class&
가 된다. 이 기능을 쓰면 최적화가 용이해지며 사용자의 실수를 줄이는 역할을 한다. 또한 수정 가능한 객체와 불가능한 객체를 따로 전달함으로써 멤버 함수의 역할 구분이 쉬워진다. 역시 대부분의 경우
const
를 붙이냐 마냐만 따지면 충분하다. 그러나 여기서 멈추면 C++이 아니다. 자료형을 정확하게 기입해야 성능에 문제가 생기지 않기 마련이다. C++11부터는 &
, &&
도 같이 추가할 수 있게 되었다. 이 한정자는 인스턴스의 이름 기준으로 붙는다. 인스턴스의 이름 그 자체는 주소가 있는 lvalue
이므로 &
, const&
중 하나가 선택된다. &&
형변환 또는 리터럴 또는 함수의 객체 반환 등 rvalue
에서는 &&
, const&&
중 하나가 선택된다. C++11 이전까지의 const
가 없는 경우와 있는 경우는 각각 &
와 const&
로 연역되며, rvalue
는 직접 형변환해줘야 한다. 역시 간편하게 하려면 const&
, &&
로 대부분을 때울 수 있다. 표준 라이브러리 조차 이 기능을 적극적으로 응용하는 부분은 std::optional
, std::expected
등 한정자를 준수하지 않으면 큰일나는 곳이 전부다.한편
volatile
까지 붙일 경우 &
, const&
, volatile&
, const volatile&
, 그리고 이것들의 rvalue
버전까지 8개를 죄다 오버로딩 해줘야 하기 때문에 아주 고역이다. 설령 volatile
을 붙이지 않아도 구현을 전부 따로 해줘야 하는 건 매우 불편하다. 이것 때문에 C++23에서는 deducing this라는 신기능이 도입됐다.6. 친구 함수
- <C++ 예제 보기>
#!syntax cpp import <utility>; import <string>; namespace NamuWiki { struct House { using name_t = std::string; name_t myName; unsigned int myAge; std::string myAddress; } // 이름공간 `NamuWiki`에 존재하는 클래스 `Person` class Person { public: using name_t = std::string; Person(const name_t name, unsigned int age) noexcept : myName(name), myAge(age), myHouse(nullptr) {} Person(const name_t name, unsigned int age, House& house) noexcept : myName(name), myAge(age), myHouse(std::addressof(house)) {} const name_t& GetName() const& noexcept { return myName; } name_t&& GetName() && noexcept { return std::move(myName); } unsigned int GetAge() const noexcept { return myAge; }; // (1) // `ChangePersonName`은 이름공간 `NamuWiki`에 선언된다 friend void ChangePersonName(Person& person, const name_t& name); // (2) // `MoveHouse`은 이름공간 `NamuWiki`에 정의된다 friend inline void MoveHouse(House& dest) { myHouse = std::addressof(dest); } // (3) // `GetMarriageWith`은 이름공간 `NamuWiki`에 선언된다 friend void GetMarriageWith(Person& lhs, Person& rhs); private: name_t myName; unsigned int myAge; House* myHouse; }; } // 정의를 `NamuWiki` 안에 해도 상관없다 void NamuWiki::ChangePersonName(NamuWiki::Person& person, const NamuWiki::Person::name_t& name) { person.myName = name; }
예약어
friend
를 클래스 안의 함수에 붙여주면 해당 함수는 클래스의 protected
, private
멤버에 접근할 수 있음을 의미한다. 마치 친구처럼 비밀도 나누는 관계가 됐음을 나타낸다. 이때 친구 함수는 클래스의 바깥에 정의된다. 이 예제에서는 Person
클래스가 있는 이름공간 NamuWiki
에 정의된다. 친구 함수를 정할 때는 즉시 함수를 정의하거나, 기존에 만들어진 함수를 지정할 수 있다. 예제에서처럼 선언만 있는 함수도 친구가 될 수 있다.7. 친구 클래스
- <C++ 예제 보기>
#!syntax cpp import <utility>; import <string>; class Person { public: // (1) // 친구 클래스 `Dog` friend class Dog; using name_t = std::string; Person(const name_t name, unsigned int age) noexcept : myName(name), myAge(age), myHouse(nullptr) {} Person(const name_t name, unsigned int age, House& house) noexcept : myName(name), myAge(age), myHouse(std::addressof(house)) {} const name_t& GetName() const& noexcept { return myName; } name_t&& GetName() && noexcept { return std::move(myName); } unsigned int GetAge() const noexcept { return myAge; }; bool HasPet() const noexcept { return nullptr == myPet; }; bool HasHouse() const noexcept { return nullptr == myHouse; }; private: name_t myName; unsigned int myAge; Dog* myPet; House* myHouse; }; // (2) // `Dog`의 정의는 어디에서나 해도 상관없다 class Dog { public: void GotoHome() { if (myFriend != nullptr) { // (3) // `Dog` 는 `Person`의 친구 클래스라서 `Person`의 private 데이터 멤버에 접근할 수 있다 // 역은 성립하지 않는다 myPlace = myFriend->myHouse; } } name_t myName; unsigned int myAge; Person* myFriend; House* myPlace; };
어떤 클래스 안에서 예약어
friend
와 함께 클래스 선언을 하면 선언된 클래스가 포함된 클래스의 protected
, private
멤버에 접근할 수 있음을 의미한다. 이때 클래스의 선언은 정확해야 한다. 템플릿, 특성, struct
/class
구분 등...- <C++ 예제 보기>
#!syntax cpp class Window { private: struct Anchor { Widget* AttachWidget(Widget* widget) noexcept { if (widget->myAnchor == this) return; if (widget->myAnchor != nullptr) { widget->myAnchor->DetachWidget(); } Widget* before = DetachWidget(); widget->myAnchor = this; myWidget = widget; return before; } Widget* DetachWidget() noexcept { auto before = myWidget; if (myWidge != nullptr) { myWidget->myAnchor = nullptr; myWidget = nullptr; } return before; } int horizontalPosition; int verticalPosition; Widget* myWidget; }; struct Widget { Anchor* myAnchor; }; public: void AddWidget(int category, int x, int y, void* data); private: Anchor myAnchors[9]; };
protected
, private
멤버에 접근할 수 있다.8. 연산자 오버로딩
- <C++ 예제 보기>
#!syntax cpp import <utility>; // (1) // `Vector3`를 전방위 선언 class Vector3; namespace std { // (2) // C++17부터는 noexcept가 함수 서명에 포함되어서 선언-구현 구조에서 반드시 지켜줘야 한다 void swap(Vector3& lhs, Vector3& rhs) noexcept; } // (3) // 전역 이름공간에 존재하는 클래스 `Vector3` class Vector3 { public: const float& X() const& noexcept { return x; } const float& Y() const& noexcept { return y; } const float& Z() const& noexcept { return z; } float&& X() && noexcept { return std::move(x); } float&& Y() && noexcept { return std::move(y); } float&& Z() && noexcept { return std::move(z); } // (4) // friend 지정에 constexpr 여부는 상관없으나, noexcept는 반드시 동치시켜야 한다 friend void std::swap(Vector3& lhs, Vector3& rhs) noexcept; // (5) // 덧셈 연산자 // 이때 `operator+`는 `Vector3` 내부에 정의된다 Vector3 operator+(const Vector3& rhs) const noexcept { return Vector3 { .x = x + rhs.x, .y = y + rhs.y, .z = z + rhs.z, }; } // (6) // 뺄셈 연산자 // 이때 `operator-`는 `Vector3` 내부가 아닌 바깥 이름공간에 정의된다 // 매개변수가 꼭 const lvalue일 필요는 없으나 성능 때문에 하는 게 좋다 friend Vector3 operator-(const Vector3& lhs, const Vector3& rhs) noexcept { return Vector3{ lhs.x - rhs.x, lhs.y - rhs.y, lhs.z - rhs.z }; } // (7) // 곱셈 연산자 // 이때 `operator*`는 `Vector3` 내부가 아닌 바깥 이름공간에 정의된다 // 매개변수가 꼭 const lvalue일 필요는 없으나 성능 때문에 하는 게 좋다 // inline이 아니라서 정의를 별도의 소스 파일에 해줘도 된다 friend Vector3 operator*(const Vector3& lhs, const Vector3& rhs) noexcept; // (8) // 나눗셈 연산자 // 이때 `operator/`는 `Vector3` 내부에 정의된다 // 매개변수가 꼭 const lvalue일 필요는 없으나 성능 때문에 하는 게 좋다 // 그러나 inline이기 때문에 반드시 클래스 정의와 함께 같은 헤더 어딘가에 정의해줘야 한다 inline Vector3 operator/(const Vector3& rhs) const noexcept; // (9) // 양의 부호 연산자 // 이때 `operator+`는 `Vector3` 내부가 아닌 바깥 이름공간에 정의된다 // 연산자는 friend가 아니면 static일 수 없다. // static friend 연산자는 헤더에서는 문제 없으나, 모듈에서는 내보낼 수 없기 때문에 주의해야 한다 static friend Vector3& operator+(Vector3& vector) noexcept { vector.x = std::abs(vector.x); vector.y = std::abs(vector.y); vector.z = std::abs(vector.z); return vector; } // (10) // 음의 부호 연산자 // 이때 `operator-`는 `Vector3` 내부에 정의된다 // 연산자는 friend가 아니면 static일 수 없다. Vector2& operator-() noexcept { x = -std::abs(x); y = -std::abs(y); z = -std::abs(z); return *this; } // (11) // 등호 연산자 // 이때 `operator==`는 `Vector3` 내부에 정의된다 bool operator==(const Vector3& rhs) const noexcept { return x == rhs.x and y == rhs.y and z == rhs.z; } float x, y, z; }; // (12) // `Vector3::operator/` 정의 Vector3 Vector3::operator/(const Vector3& rhs) const noexcept { return Vector3 { .x = x / rhs.x, .y = y / rhs.y, .z = z / rhs.z, }; } namespace std { constexpr void swap(const Vector3& lhs, const Vector3& rhs) noexcept { using ::std::swap; // 필요하다. 자세한 내용은 C++/문법 문서로.. swap(lhs.x, rhs.x); swap(lhs.Y, rhs.Y); swap(lhs.z, rhs.z); } }
C++의 특별한 기능이라고 할만한 것은 템플릿 다음으로는 사용자가 연산자를 직접 정의할 수 있다는 점이다 [12]. 이렇게 연산자 동작을 함수의 형태로 재정의하는 것을 연산자 오버로딩이라고 한다. 예를 들면 단순히 클래스로 숫자를 감싸 내부에서 연산을 처리할 수도 있다. 또는
==
, <
혹은 <=>
C++20 연산자를 재정의하면 비교 연산도 수행할 수 있다. 잘 이용하면 프로그램에서 모든 클래스가 시시각각 바뀌는 메서드 이름이나 가상 함수에 의존하지 않고 연산자만 써서 프로그램 내내 일관적인 프로그램 논리를 구현할 수 있다. 아예 연산자 기호를 창조할 수 있는 함수형 언어들을 제외하면, 어떤 언어와도 비교 불가능할만큼 자유롭게 연산자의 동작을 바꿀 수 있다. 아예 원래 용도와 완전히 다른 기작을 만들 수도 있다. 라이브러리 중에는
--
연산자와 >
연산자를 오버로딩해서 -->
모양의 변환 연산자를 만들기도 했다. 또한 표준 라이브러리의 많은 클래스가 ==
, <=>
C++20 또는 사칙 연산자들을 지원한다. 클래스에 연산자 오버로딩을 구현할 경우
friend
, friend inline
혹은 static friend
지시자를 사용할 수 있다 [13]. 주의할 점은 연산자 오버로딩에는 원래 연산자가 쓰이는 위치대로 인자의 개수와 반환해야하는 자료형에 대해 제약이나 권고 사항이 있다. 예를 들어 =
및 단항 연산자의 정의는 클래스 내부에 있어야 한다, 등호 연산자의 반환형은 bool
이여야 하고, const
메서드로 구현해야 한다든가 등. 컴파일 오류가 뜨기 전까지는 권고 사항을 바로 알 수는 없지만[14] 어쨌든 경험이나 관습 상 연산자를 만들고 정의하는 데에는 문제가 없다. 그리고 static
연산자는 C++20의 모듈에서 export
를 사용할 수 없으므로 이것도 주의해야 한다.9. 변환 함수
- <C++ 예제 보기>
#!syntax cpp class Integer { public: explicit Integer() noexcept : myValue() {} explicit Integer(int value) noexcept : myValue(value) {} operator int() const noexcept { return myValue; } operator size_t() const noexcept { return static_cast<size_t>(myValue); } private: int myValue; }; int Add(int lhs, int rhs) noexcept { return lhs + rhs; } size_t Add(size_t lhs, size_t rhs) noexcept { return lhs + rhs; } int AddLvalue(int lhs, const int& rhs) noexcept { return lhs + rhs; } int AddRvalue(int lhs, int&& rhs) noexcept { return lhs + rhs; } int main() { const Integer integer{ 100 }; // (1) // result_0은 6600 int result_0 = Add(6500, integer); // (2) // result_1은 6100ULL size_t result_1 = Add(6000ULL, integer); // (3) // const lvalue에 대입도 가능 // result_2는 5600 int result_2 = Add(5500, integer); // (4) // rvalue에 대입도 가능 // result_3은 5100 int result_3 = Add(5000, integer); // (5) int result_4_0 = (int)integer; // C 스타일 int result_4_1 = int(integer); // C 스타일 int result_4_2 = static_cast<int>(integer); // 정석 int result_4_3 = const_cast<int>(integer); // 문제 없음 int result_4_4 = auto(integer); // C++23, auto result_4_4는 안됨 int result_4_5 = auto{ integer }; // C++23, auto result_4_5는 안됨 // (6) // 오류! 모호한 함수 후보! auto result_5 = Add(Integer{ 10 }, Integer{ 20 }); }
변환 함수(Conversion Function)는 함수로 구현하는 것 말고도 C++ 자체의 기능으로도 제공한다. 변환 함수는 형변환 연산자라고도 부르며 클래스의 인스턴스를 다른 자료형으로 형변환하는데에 도움을 준다. 변환 함수는 암시적으로 수행하는 것, 명시적으로 수행하는 것 두 종류가 있다. 상단의 예제는 암시적으로 수행되는 변환 함수다. 클래스
Integer
는 int
, size_t
}로 자동으로 변환되는 멤버 함수를 갖고 있다.참고로 크기가 있는 배열 혹은 함수 포인터로 변환할 수는 없다.
- <C++ 예제 보기>
#!syntax cpp import <utility>; class Integer { public: explicit Integer() noexcept : myValue() {} explicit Integer(int value) noexcept : myValue(value) {} operator int&() & noexcept { return myValue; } operator const int&() const& noexcept { return myValue; } operator int&&() && noexcept { return std::move(myValue); } operator const int&&() const&& noexcept { return std::move(myValue); } private: int myValue; };
const&
혹은 &&
중 하나가 적절하게 사용된다.9.1. explicit
- <C++ 예제 보기>
#!syntax cpp import <utility>; class Integer { public: explicit Integer() noexcept : myValue() {} explicit Integer(int value) noexcept : myValue(value) {} explicit operator int&() & noexcept { return myValue; } explicit operator const int&() const& noexcept { return myValue; } explicit operator int&&() && noexcept { return std::move(myValue); } explicit operator const int&&() const&& noexcept { return std::move(myValue); } private: int myValue; };
원시자료형이 암시적으로 형변환된다는 사실을 기억하는가? 이는 편리함을 가져다 주지만 문제점을 가져다 준다는 것도 앞 문단에서 알아보았다. 클래스도 마찬가지다. 원시자료형보다 복잡한 클래스가 아무 예고도 없이 변신하는 건 좋은 현상이 아니다. 앞서 생성자에서 썼던 것 처럼,
explicit
을 이용해 변환 함수를 반드시 static_cast
, dynamic_cast
, const_cast
, reinterpret_cast
, 혹은 C스타일 형변환을 써야 하도록 만들 수 있다. 사실상 이게 없는 변환 함수는 너무 위험하기 때문에 무조건 explicit
을 붙이는 걸 추천한다. 불편함의 문제가 아니라 버그가 생길 확률이 너무 높고 예측할 수 없는 동작을 하기 때문이다. 그리고 암시적 변환이 많으면 코드를 작성하기가 너무 힘들어진다.10. 특수 멤버 함수
10.1. 복사와 이동
복사/이동 생략맨 처음 문단에서 C++의 클래스는 정보가 어떻게 흘러가는 지 정의한 규칙 모음이라고 말했었다. 그리고 자료형 문서에서 귀에 못이 박히도록 복사, 이동, 값 범주(Value Category)에 대해 이야기했었다. 이 모든 것은 C++에서 객체, 곧 데이터의 수명을 추적하고 흐름을 파악할 수 있도록 하는 수단이다. 값 범주 이론은 당연히 클래스에도 적용된다. 사용자는 클래스 객체가 복사 가능한지 또는 어떻게 복사되는지, 이동 가능한지 또는 어떻게 이동시킬 수 있는지를 모두 정의할 수 있다. 이제 소개할 복사 생성자, 이동 생성자, 복사 대입 연산자, 이동 대입 연산자로 완성된다.
입문 단계에서는 복사와 이동 논리를 신경 쓸 필요도 이유도 없다. 왜냐하면 C++이 알아서 처리해주는 게 매우 많기 때문이다. 가령 지금까지 예제에서 만들었던 클래스들은 모두 암시적으로 복사 및 이동이 가능하다. C++에서 묵시적으로 복사, 이동 생성자와 대입 연산자를 만들어주기 때문이다. 사용자 단에서 거대한 규모의 클래스 라이브러리를 만들지 않는다면 정말 알아야 할까 싶은 내용이기도 하다. 그러나 멤버 관련해서 문제가 생기는 부분을 아직 짚고 넘어가지 않았다. 그리고 낮은 진입장벽으로 분명한 성능적 이득을 볼 수 있으므로 C++을 쓰면서 알아도 손해보는 내용은 아니다.
10.2. 복사 생성자
class Class
{ public: Class ( Class &); Class ( const Class &); Class ( volatile Class &); Class ( const volatile Class &); }; |
복사 생성자는 인자로 같은 클래스의
lvalue
인스턴스들을 받는 생성자다. 복사 생성자를 사용하면 기존에 만들었던 클래스의 데이터 멤버를 복사하면서, 새로운 인스턴스를 만들 수 있다. 사용법은 간단하게 생성자의 인자로 먼저 만들어진 인스턴스를 전달하는 것이다. 가령 속성이 조금씩 다른 인스턴스들을 만들 때는 인자들을 전달하는 작업이 반복적인데 복사 생성자를 쓰면 번거로움을 줄일 수 있다.- <C++ 예제 보기>
#!syntax cpp class Integer { public: explicit Integer() noexcept : myValue() {} explicit Integer(int value) noexcept : myValue(value) {} // (1) // 복사 생성자 explicit Integer(const Integer& other) noexcept : myValue(other.myValue) {} // (2) // 구식 값 생성자 explicit Integer(Integer other) noexcept : myValue(other.myValue) {} int myValue; }; // (3) Integer Add5000(const Integer& integer) noexcept { Integer result; result.myValue = integer.myValue + 5000; // 여기서 임시 객체와 함께 복사 생성자가 실행된다 return result; } // (4) // RVO Integer Add5000WithNoCost(const Integer& integer) noexcept { return Integer { .myValue = integer.myValue }; } int main() { Integer my_integer0{ 3000 }; // (5) // lvalue를 받아서 복사 생성 수행 // my_integer1.myValue는 3000 Integer my_integer1{ my_integer0 }; // (6) // prvalue를 받아서 복사 생성 수행 // my_integer2.myValue는 200 Integer my_integer2{ Integer{ 200 } }; // (7) // xvalue를 받아서 복사 생성 수행 // my_integer3.myValue는 3000 Integer my_integer3{ std::move(my_integer0) }; // (8) // prvalue를 받아서 복사 생성 수행 // my_integer3.myValue는 6000 Integer my_integer4{ Add5000WithNoCost(Integer{ 1000 }) }; }
const&
가 rvalue
도 받으면서 임시 객체가 만들어지며 여기서 문제가 발생한다. 이것 때문에 C++11에서 rvalue
만을 받는 이동 생성자가 추가되었다.10.2.1. 암시적 복사 생성자
C++에서는 비정적 데이터 멤버들이 복사가능할 때, 기본적으로 보이지 않는 복사 생성자를 만들어준다. 이 보이지 않는 복사 생성자를 암시적 복사 생성자(Implicit Copy Constructor)라고 칭한다. 암시적 복사 생성자는 이름대로explicit
(명시적) 생성자가 아니다. 암시적 복사 생성자는 Class(const Class&);
형태로서 volatile
은 받지 않는다.암시적 복사 생성자의 생성 조건은 다음과 같다. 비정적 데이터 멤버 중에
&&
참조형 멤버가 있다면 만들어지지 않는다. 이동 생성자 혹은 이동 대입 연산자가 있다면 만들어지지 않는다. 클래스가 이동 연산만 지원하는 걸 상정한 규칙이다. 소멸자가 삭제된 인스턴스 멤버, 소멸자가 private
등의 권한 때문에 접근할 수 없는 인스턴스 멤버, 또는 이상한 복사 생성자가 있는 인스턴스 멤버, 혹은 결합체(Union) 멤버가 있으면 복사 생성자가 자동으로 만들어지지 않는다. 그외엔 사용자가 클래스에 이미 다른 종류의 생성자를 만들었더라도 데이터 멤버가 복사 가능하다면 알아서 만들어진다.10.3. 복사 대입 연산자
class Class
{ public: Class & operator= ( Class &); Class & operator= ( const Class &); Class & operator= ( volatile Class &); Class & operator= ( const volatile Class &); }; |
복사 대입 연산자는 인자로 같은 클래스의
lvalue
인스턴스들을 받는 대입 연산자다.- <C++ 예제 보기>
#!syntax cpp class Integer { public: explicit Integer() noexcept : myValue() {} explicit Integer(int value) noexcept : myValue(value) {} explicit Integer(const Integer& other) noexcept : myValue(other.myValue) {} // 복사 대입 연산자 Integer& operator=(const Integer& other) noexcept { myValue = other.myValue; return *this; } // `Integer` 클래스의 생성자가 explicit이 아니면 문제가 생길 수 있다 Integer& operator=(const int& value) noexcept { myValue = value; return *this; } int myValue; };
std::is_copy_constructible
, std::is_copy_assignable
의 구분은 이를 위한 것이다. 그러나 제약조건 std::copyable
은 둘 모두를 검사하므로 이를 사용하기 위해서는 복사 생성자, 복사 대입 연산자를 둘 다 제대로 구현해줘야 한다.10.3.1. 암시적 복사 대입 연산자
만약 복사 생성자가 암시적으로 존재하면, 마찬가지로 암시적 복사 대입 연산자도 존재한다. 이 보이지 않는 대입 연산자를 암시적 복사 대입 연산자(Implicit Copy Assignment Operator)라고 칭한다. 암시적 복사 대입 연산자는Class& operator=(const Class&);
형태로서 volatile
은 받지 않는다. 또한 인스턴스 자신의 비상수 참조형을 반환한다.10.4. 이동 생성자
class Class
{ public: Class ( Class &&); Class ( const Class &&); Class ( volatile Class &&); Class ( const volatile Class &&); }; |
이동 생성자는 클래스의
xvalue
, prvalue
인스턴스 만을 받을 수 있는 생성자다. lvalue
는 받을 수 없다.- <C++ 예제 보기>
#!syntax cpp import <utility>; class Integer { public: explicit Integer() noexcept : myValue() {} explicit Integer(int value) noexcept : myValue(value) {} // (1) // 이동 생성자 // 이때 복사 생성자, 복사 대입 연산자는 자동으로 생성되지 않는다 explicit Integer(Integer&& other) noexcept : myValue(std::move(other.myValue)) // 표준 라이브러리의 std::move 사용 : myValue(static_cast<int&&>(other.myValue)) // 또는 직접 rvalue 형변환 {} // (2) // 구식 값 생성자 //explicit Integer(Integer other) noexcept // : myValue(other.myValue) // 이동만 할 수 있는 클래스가 아니면 문제 없음 // : myValue(std::move(other.myValue)) // 값 생성자의 `other`에 값이 들어오는 순간에는 이동이 수행되므로 문제 없음 //{} int myValue; }; int main() { Integer my_integer0{ 3000 }; // (3) // xvalue를 받아서 이동 생성 수행 // my_integer1.myValue는 3000 Integer my_integer1{ std::move(my_integer0) }; // (4) // prvalue를 받아서 이동 생성 수행 // my_integer2.myValue는 300 Integer my_integer2{ Integer{ 300 } }; // (5) // lvalue를 받아서 생성 수행 시도 // 오류! rvalue는 lvalue를 받을 수 없습니다. Integer my_integer3{ my_integer0 }; }
`Integer{ 300 }`
이 아예 존재하지 않았던 것처럼 내부에 값으로 바로 전달된다.10.4.1. 암시적 이동 생성자
C++에서는 비정적 데이터 멤버들이 이동가능할 때, 기본적으로 보이지 않는 이동 생성자를 만들어준다. 이 보이지 않는 이동 생성자를 암시적 이동 생성자(Implicit Move Constructor)라고 칭한다. 암시적 이동 생성자는 이름대로explicit
(명시적) 생성자가 아니다. 암시적 이동 생성자는 Class(Class&&);
형태로서 const
및 volatile
은 받지 않는다.암시적 이동 생성자의 생성 조건은 다음과 같다. 암시적이 아니고 사용자가 직접 정의한 복사 생성자, 복사 대입 연산자, 이동 대입 연산자가 있는 경우 만들어지지 않는다. 그리고 소멸자가 삭제된 인스턴스 멤버, 소멸자가
private
등의 권한 때문에 접근할 수 없는 인스턴스 멤버, 또는 이상한 이동 생성자가 있는 인스턴스 멤버, 혹은 결합체(Union) 멤버가 있으면 이동 생성자가 자동으로 만들어지지 않는다. 그외엔 사용자가 클래스에 이미 다른 종류의 생성자를 만들었더라도 데이터 멤버가 이동 가능하다면 알아서 만들어진다.10.5. 이동 대입 연산자
class Class
{ public: Class & operator= ( Class &&); Class & operator= ( const Class &&); Class & operator= ( volatile Class &&); Class & operator= ( const volatile Class &&); }; |
이동 대입 연산자는 인자로 같은 클래스의
rvalue
인스턴스들을 받는 대입 연산자다.10.5.1. 암시적 이동 대입 연산자
- <C++ 예제 보기>
#!syntax cpp import <utility>; class Integer { public: explicit Integer() noexcept : myValue() {} explicit Integer(int value) noexcept : myValue(value) {} explicit Integer(Integer&& other) noexcept : myValue(std::move(other.myValue)) {} // 이동 대입 연산자 Integer& operator=(Integer&& other) noexcept { myValue = std::move(other.myValue); return *this; } Integer& operator=(const int& value) noexcept { myValue = value; return *this; } Integer& operator=(int&& value) noexcept { myValue = std::move(value); return *this; } int myValue; };
Class& operator=(Class&&);
형태로서 volatile
은 받지 않는다. 또한 인스턴스 자신의 비상수 참조형을 반환한다. 그런데 이동이 불가능한 클래스에 새로 이동 생성자를 만들어도 이동 대입 연산자가 자동으로 정의되지는 않는다. 표준 라이브러리의 std::is_move_constructible
, std::is_move_assignable
의 구분은 이를 위한 것이다. 그러나 제약조건 std::movable
은 둘 모두를 검사하므로 이를 사용하기 위해서는 이동 생성자, 이동 대입 연산자를 둘 다 제대로 구현해줘야 한다.10.6. 등호 연산자
class Class
{ public: bool operator== ( const Class &) const ; }; |
등호 연산자, 혹은 동등 비교 연산자는
==
로 수행하는 인스턴스 간의 동일성을 판단하는 연산자다. 이 연산자 역시 암시적으로 정의된다. 표준에선 이 연산자는 prvalue
bool
을 반환하도록 하고 있는데 쉽게 말해서 반환형은 오직 bool
말고 다른건 쓰지 말라는 뜻이다. C++의 ==
연산자는 다른 언어의 ===
연산자와 같은 역할을 하도록 하고있다. 그래서 동치(Equivalence)가 아닌 동등(Equality) 연산자다. 적당히 같다고 치는 동치의 경우 후술할 3방향 비교 연산자를 사용해야 한다.10.7. 3방향 비교 연산자
import <compare> ; class Class { public: std :: strong_ordering operator<=> ( const Class &) const ; }; |
우주선 연산자라고도 부르는 3방향 비교 연산자는 C++20에서 추가된 비교 연산자다. C++20 이전까지의 관계 연산자(
<
, <=
, >
, >=
)들을 한번에 대체하기 위해 추가되었다. 이 연산자는 다른 언어에서 보이는 CompareTo()
류의 함수와 동등한 역할을 수행한다. <=>
연산자에서 반환하는 비교 결과 객체들은 -1
, 0
, +1
혹은 비교 결과에 따른 값을 내부에 들고 있다. 최종적으로는 int
내지는 정수형으로 반환되어 이 값이 0
보다 큰지 작은지에 따라 <
, <=
, >
, >=
를 수행할지 말지를 결정한다. 이 연산자를 사용하려면 <compare>
를 사용해야 한다. 이는 C++에서 3번째로 언어 기능이 라이브러리 구현에 의존하는 요소다 [15].원래 C++은
==
, <
연산자만 추가하면 알아서 다른 비교 연산자를 정의해줬으나 이 또한 문제가 있었다. 현재 비교하는 방법에서 어떻게 인스턴스를 비교하는 지 알 수가 없었다. 그리고 연산자 함수가 inline
이 아니면[16] 비교하는 것 조차 호출 오버헤드가 있었다. ==
, <
에서 파생되는 다른 연산자들은 !
등을 붙여 역, 이, 대우로 만들어지는데 이 또한 오버헤드가 있다. 3방향 비교 연산자는 비교 과정을 단순화시켜 성능에 유리함이 있다. 그리고 구현자 입장에서는 비교 방식을 엄밀하게 정의할 수 있으며, 사용자 입장에서도 어떻게 비교가 이루어질지 알 수 있다. 또한 지원만 한다면 비교한 결과를 단순한 bool
뿐만 아니라 비교한 차이 값을 알아낼 수도 있다.<rowcolor=#d7d7d7,#a1a1a1> | std::weak_ordering | std::strong_ordering | std::partial_ordering |
이전 순서 | <rowcolor=#090912,#bebebf>less | less | less |
다음 순서 | <rowcolor=#090912,#bebebf>greater | greater | greater |
동치 | <rowcolor=#090912,#bebebf>equivalent | equivalent | equivalent |
동등 | <rowcolor=#090912,#bebebf>- | equal | - |
비교 불가능 | <rowcolor=#090912,#bebebf>- | - | unordered |
변환 가능성 | <rowcolor=#090912,#bebebf>std::partial_ordering | std::weak_ordering std::partial_ordering | - |
std::weak_ordering
, std::strong_ordering
, std::partial_ordering
3가지를 지원한다. 이 세가지 클래스는 비교하는 방식, 제공하는 비교 상태 상수가 다르다. 또한 그 동안 제공하던 관계 함자 클래스와 같은 맥락으로 함자 클래스 std::compare_three_way
를 지원한다. 각 경우의 수에서 std::strong_ordering
이 아니면 동등한 결과를 반환하는 경우가 없다. 때문에 ==
와 <=>
연산자는 서로 역할이 겹치지 않고 공존할 수 있다. 동치와 동등의 차이는 정말로 완전히 동일한 객체인가, 아니면 일부 값 혹은 적당한 조건만 있으면 같다고 판정할 수 있는지의 차이다. 표준에서는 동등한 객체는
lhs == rhs
가 동등함을 판정한다. 그리고 !(lhs < rhs) && !(rhs < lhs)
가 동치임을 판정한다. 정말로 완전 동일한 객체라면 주소를 비교해서 같은지 비교할 수 있을 것이다. 하지만 주소를 비교하는 건 lvalue
에서만 쓸 수 있으므로 적절하진 않다. 적당한 조건의 예시는 표준 라이브러리의 연관 컨테이너에서 값을 삽입하거나 찾아낼 때 lhs == rhs
대신 !(lhs < rhs)
처럼 비교하는 경우가 있다. 이때 비교해서 참이 나온 객체 둘이 정말 동일한 존재인지는 모르겠지만 어쨌든 연관 컨테이너 안에서는 같은 걸로 퉁치는 것이다. std::partial_ordering
은 비교가 실패할 경우도 상정하고 있다. C++20부터 표준 라이브러리의 부동 소수점 비교는 std::partial_ordering
을 쓰는데, 정수형과는 다른 규칙이 많기 때문이다. 가령 부동 소수점의 -0.0f
, +0.0f
는 서로 다른 값이지만 서로 동치다. Infinity
, NaN
등과 비교하는 경우 std::partial_ordering::unordered
가 반환된다.<=>
연산자는 암시적으로 생성되지는 않지만 후술할 default
예약어를 써서 생성할 수 있다. 이렇게 생성되는 <=>
연산자는 클래스의 비정적 데이터 멤버들을 전부, 선언 순서대로 비교한다.11. delete
import <compare> ; class Class { public: Class () = delete ; ~Class () = delete ; Class ( const Class &) = delete ; Class ( Class &&) = delete }; Class & operator= ( const Class &) = delete ; Class & operator= ( Class &&) = delete ; bool operator== ( const Class &) const = delete ; std :: strong_ordering operator<=> ( const Class &) const = delete ; }; |
delete
예약어를 사용하면 해당 멤버 함수의 사용을 막을 수 있다. 함수가 구현되지 않았을 때 나는 오류를 사용자가 임의로 발생시킬 수 있는 기능이다. 정확한 기전은 먼저 해당 함수를 선언하고, 해당 함수가 사용되면 문법 오류 혹은 링크 오류를 발생시키는 것이다. 이런 종류의 오류는 사용자는 즉시 무엇이 문제인지 알 수 있다. 특수 멤버 함수 뿐만 아니라 모든 비정적/정적 함수에 사용할 수 있다. 그래도 주로 쓰이는 용도는 클래스의 복사/이동 여부를 결정하는 데 쓰인다. 이 기능을 쓰면 암시적으로 생성되는 멤버 함수들을 지울 수 있다. 가령 복사 생성만 지원하는 클래스를 만들려고 할 때 원래대로라면 복사 생성자를 구현해주는 수고가 들었지만, delete
를 써서 원하는 멤버 함수만 선별할 수 있다.12. default
import <compare> ; class Class { public: Class () = default ; ~Class () = default ; Class ( const Class &) = default ; Class ( Class &&) = default ; Class & operator= ( const Class &) = default ; Class & operator= ( Class &&) = default ; bool operator== ( const Class &) const = default ; std :: strong_ordering operator<=> ( const Class &) const = default ; }; |
= default
구문으로 특수 함수들이 암시적으로 생성되도록 지시할 수 있다. 가령 이동 생성자를 직접 정의하면 원래대로라면 복사 생성자, 복사 대입 연산자는 사라질 것이다. 그러나 Class(const Class&) = default;
와 같이 쓰면 직접 구현할 필요도 없이 복사 생성자를 만들 수 있다. 그런데 항상 성공하는 건 아니다. 바로 비정적 데이터 멤버들이 멤버 함수를 생성하는데에 적합해야(Eligible) 한다. 이 조건은 추후에 설명한다.13. 클래스 활용 예제
- <C++ 예제 보기>
#!syntax cpp #include <time.h> #include <threads.h> import <cstdio>; import <type_traits>; import <utility>; import <memory>; import <expected>; class Thread; // std::thread::id class ThreadId final { public: using handle_t = ::thrd_t; friend class Thread; constexpr ThreadId(handle_t&& handle) noexcept(std::is_nothrow_move_constructible_v<handle_t>) : myHandle(std::move(handle)) { } [[nodiscard]] friend inline bool operator==(const ThreadId& lhs, const ThreadId& rhs) noexcept { return ::thrd_equal(*lhs, *rhs); } [[nodiscard]] friend inline bool operator==(const ThreadId& lhs, const handle_t& rhs) noexcept { return ::thrd_equal(*lhs, rhs); } ThreadId(ThreadId&&) noexcept(std::is_nothrow_move_constructible_v<handle_t>) = default; ThreadId& operator=(ThreadId&&) noexcept(std::is_nothrow_move_assignable_v<handle_t>) = default; private: ThreadId() noexcept(std::is_nothrow_default_constructible_v<handle_t>) = default; ThreadId(const ThreadId&) = delete; ThreadId& operator=(const ThreadId&) = delete; [[nodiscard]] handle_t& operator*() & noexcept { return myHandle; } [[nodiscard]] const handle_t& operator*() const& noexcept { return myHandle; } [[nodiscard]] handle_t&& operator*() && noexcept { return std::move(myHandle); } [[nodiscard]] const handle_t&& operator*() const&& noexcept { return std::move(myHandle); } handle_t myHandle; }; class Thread final { public: // function_t: int(*)(void*); using function_t = ::thrd_start_t; using id_t = ThreadId; using succeed_t = decltype(::thrd_success); Thread() noexcept : myId(nullptr) { }; explicit Thread(function_t function, void* data = nullptr) : myId(nullptr) { std::allocator<id_t> alloc{}; myId = alloc.allocate(1); ::thrd_create(std::addressof(**myId), function, data); } // 이동된 상대의 핸들을 날려버린다 // 안날린다면 prvalue면 문제가 없으나 xvalue면 이동된 원본이 그대로 남아있으므로 큰 문제가 발생한다 explicit constexpr Thread(Thread&& other) noexcept : myId(std::exchange(other.myId, nullptr)) { } explicit constexpr Thread(id_t&& id) : myId(nullptr) { std::allocator<id_t> alloc{}; myId = alloc.allocate(1); *myId = id_t{ std::move(id) }; } constexpr Thread& operator=(Thread&& other) noexcept { myId = std::exchange(other.myId, nullptr); return *this; } ~Thread() noexcept { if (nullptr != myId) { if (int error_code{}; thrd_error == ::thrd_join(**myId, &error_code)) { std::printf("Error occured: %d\n", error_code); } std::destroy_at(std::exchange(myId, nullptr)); } } std::expected<succeed_t, int> Join() const noexcept { if (nullptr != myId) { int terminter_code{}; if (::thrd_success == ::thrd_join(**myId, std::addressof(terminter_code))) { return ::thrd_success; } else { return std::unexpected{ terminter_code }; } } else { return ::thrd_error; } } bool Detach() const noexcept { if (nullptr != myId) { return ::thrd_success == ::thrd_detach(**myId); } else { return false; } } [[nodiscard]] constexpr const id_t& NativeHandle() const { return *myId; } [[nodiscard]] friend inline bool operator==(const Thread& lhs, const Thread& rhs) noexcept { if (lhs) // nullptr == lhs.myId { if (rhs) // nullptr == rhs.myId { return true; } else { return false; } } else { return *lhs.myId == *rhs.myId; } } [[nodiscard]] friend inline bool operator==(const Thread& lhs, const id_t& rhs) noexcept { if (lhs) // nullptr == lhs.myId { return false; } else { return *lhs.myId == rhs; } } [[nodiscard]] explicit constexpr operator bool() const noexcept { return nullptr != myId; } // std::this_thread::get_id [[nodiscard]] static id_t CurrentID() noexcept { return ::thrd_current(); } // std::this_thread::sleep_until static bool SleepUntil(const ::time_t& clock) noexcept { const ::time_t beg = ::time(0); if (const double diff = ::difftime(beg, clock); 0 <= diff) { ::timespec duration{}; duration.tv_sec = static_cast<::time_t>(diff); // 0 : success, -1 : user interupted by signals, -2... : error return ::thrd_sleep(std::addressof(duration), nullptr) < -1; } else { return false; } } // std::this_thread::sleep_for static bool SleepUntilFor(const ::timespec& duration) noexcept { return ::thrd_sleep(std::addressof(duration), nullptr) < -1; } static bool SleepUntilFor(::timespec&& duration) noexcept { ::timespec moved_duration = std::move(duration); return ::thrd_sleep(std::addressof(moved_duration), nullptr) < -1; } // std::this_thread::yield static void Yield() noexcept { ::thrd_yield(); } private: id_t* myId; };
static
또는 inline
이며 또한 noexcept
로 가정하고 구현한다.14. 상속
class BaseClass {}; class DerivedClass : {{{#DodgerBlue,#CornFlowerBlue 'access-specifier '}}} BaseClass { ... }; |
어떤 클래스는 다른 클래스와 종속관계를 형성하고 속성을 가져올 수 있다. 이것을 상속이라고 한다. 상속은 객체 지향언어에서 가장 핵심이 되는 요소다. 상속으로 클래스를 재사용하고 작성해야할 코드의 양을 줄일 수 있다. 같은 동작을 하나의 인터페이스로 통일할 수 있다. 클래스의 핵심을 제외하고 사용에 불필요한 요소를 숨길 수 있다. 여기서 상속의 대상이 되는 클래스를 기반 클래스(Base Class), 상속을 받는 클래스를 파생 클래스(Derived Class)라고 칭한다. 더 친근한 용어로는 부모 클래스(Parent Class)와 자식 클래스(Child Class)라고 부른다.
파생 클래스 멤버 | private 상속 | protected 상속 | public 상속 |
private 멤버 | 접근 불가 | 접근 불가 | 접근 불가 |
protected 멤버 | 접근 불가 | 자신만 접근[protected] | 자신만 접근[protected] |
public 멤버 | 접근 불가 | 자신만 접근[protected] | 공개적 접근[public] |
class [파생 클래스 식별자] : [접근성 지시자] [기반 클래스 식별자]
의 형식으로 상속을 수행할 수 있다. 기반 클래스에서 속성을 가져오는 방법을 접근성 지시자를 통해 결정할 수 있다. 멤버에 대한 접근 권한과 똑같이 private
, protected
, public
이 있으나 둘을 혼동하면 안된다. 상속 방식에 따라서 파생 클래스에서 접근하는 부모 클래스의 멤버에 대한 접근 권한이 달라진다. private
관계에선 오직 파생 클래스만이 기반 클래스의 멤버에 접근할 수 있도록 만든다. protected
관계는 파생 클래스의 멤버를 상속받되 외부에서는 접근할 수 없게 만든다. 그리고 해당 파생 클래스를 상속받는 또 다른 클래스에서도 기반 클래스의 멤버에 접근할 수 있다. public
관계일 때는 파생 클래스에서는 기반 클래스의 public
, protected
멤버를 사용할 수 있다. 파생 클래스와 기반 클래스의 속성은 이름이 달라도, 같아도 상관 없다. 기반 클래스의 멤버를 호출하고 싶다면 그냥 하면 된다. 그러나 이름이 겹치는 멤버는 구분해줘야 한다. 기반 클래스 이름::멤버 식별자
와 같이 사용해야 한다.만약 비정적 데이터 멤버가 없는 기반 클래스는 Empty base optimization 규칙이 적용되어 파생 클래스의 바이트 크기에 영향을 주지 않는다.
- <C++ 예제 보기>
#!syntax cpp import <string>; import <print>; class MyBase { public: void print() { std::println("My number is {}", myNumber); // My number is 64 } std::string myName; protected: int myNumber = 64; }; class MyDerived : public MyBase { public: void print() { // (1) // MyDerived의 데이터 멤버 `myNumber`를 사용한다 std::println("My number is {}", myNumber); // My number is 128 } void print_of_parent() { // (2) // 정적 데이터 멤버를 참조하는 것이 아니라, 기반 클래스의 비정적 데이터 멤버 `myNumber`를 가져온다 std::println("Parent's number is {}", MyDerived::myNumber); // My number is 64 } void legacy_of_void() { // (3) // 정적 멤버 함수를 호출하는 것이 아니라, 기반 클래스의 `print`를 호출한다 MyBase::print(); } private: // (4) // `MyBase`의 데이터 멤버 `myNumber`를 숨긴다 int myNumber = 128; // (5) // `MyBase`의 데이터 멤버 `myNumber`를 이 클래스 한정으로 숨긴다 std::string myName; }; int main() { MyDerived child{}; // (6) // `MyBase`의 public 멤버 `myName`를 참조한다 child.myName; }
MyDerived
는 부모 클래스인 MyBase
로부터 public
방식으로 상속받았다. 자식 클래스는 부모 클래스인 MyBase
로부터 데이터 멤버 myNumber
와 멤버 함수 print()
를 물려받는다. MyBase
클래스에서 멤버 myNumber
를 protected
권한으로 선언하고, public
상속을 했기 때문에 접근이 가능하다. 여기서 두 클래스의 멤버 이름이 중복되는데, 상속 구조에선 문제가 없다. 데이터 멤버와 멤버 함수 모두 부모 클래스의 이름에 ::
를 붙여 사용할 수 있다.14.1. 선택적 멤버 상속
class DerivedClass : {{{#DodgerBlue,#CornFlowerBlue 'access-specifier '}}} BaseClass { access-specifier: using BaseClass :: 데이터 멤버 ; using BaseClass :: 멤버 함수 ; using BaseClass :: 멤버 자료형 ; }; |
14.2. 다중 상속
class DerivedClass : {{{#DodgerBlue,#CornFlowerBlue 'access-specifier1 }}} Mixin1 , {{{#DodgerBlue,#CornFlowerBlue access-specifier2 '}}} Mixin2 , ... { ... }; |
using super = BaseClass;
와 같이 자료형 별칭을 쓰면 간단하게 명시할 수 있다.- <C++ 예제 보기>
#!syntax cpp class BruceWayne : public RichMan, private Batman { ... };
BruceWayne
은 RichMan
라는 클래스로부터 public
상속을 받고, Batman
라는 클래스에서는 private
상속을 받는다.14.3. 다형성
DerivedClass * child; BaseClass * parent = static_cast < BaseClass *>(child); BaseClass * child_again = dynamic_cast < DerivedClass *>(parent); |
상속의 목적은 코드 재사용도 있지만 보다 유연한 코드를 작성하는 데에도 목적이 있다. 클래스 사이의 관계는 종속, 포함이 있는데 상속은 파생 클래스가 기반 클래스에 종속적인 관계로서 파생 클래스가 곧 기반 클래스라고 취급할 수 있어야 한다. C++에선 파생 클래스를 기반 클래스로 형변환 할 수 있다. 그러면 이렇게 변환됐을 때 멤버를 사용하면 어떤 멤버가 사용되느냐 하면 기반 클래스의 멤버가 사용된다. 기반 클래스로 형변환되면서 파생 클래스의 멤버 정보는 사라지는 것이다. C언어 때의 상황처럼 함수 오버로딩 멤버 함수가 오버로딩되지 못하고 숨어있어야 할 기반 클래스의 멤버가 나와버린다. 그럼 클래스의 동작은 기반 클래스에서 단 한치도 나아가지 못하고 유연성은 물건너 간 셈이다. 좀 더 유동적이고 사용자 친화적인 코드를 위해 클래스를 도입한 것인데 아주 단단한 돌덩어리를 던져 놓은 거나 다름없다. 그리고 앞서 봤듯이 데이터 멤버와 멤버 함수를 덮어 씌울 수 있는 건 알았는데 과연 이게 적절한 사용법인지 묻는다면 전부는 아니라고 말할 수 있다. 그래서 C++에서는 성능을 다소 희생하고
virtual
로 대표되는 다형성 기능을 제공한다. 그러나 성능 문제 때문에 C++에선 이 기능을 회피할 수 있는 경로를 제공하고 있다.파생 클래스의 참조형과 포인터를 각각 기반 클래스의 참조형 및 포인터로 변환할 수 있다.
reinterpret_cast
나 C스타일 형변환도 가능하지만 그것 보다는 static_cast
와 dynamic_cast
로 상수 시간에 할 수 있다.14.3.1. 가상 멤버 함수
class BaseClass { access-specifier: virtual 반환 자료형 멤버 함수 식별자 ( 매개변수); }; class DerivedClass : access-specifier BaseClass { access-specifier: {{{#DodgerBlue,#CornFlowerBlue ' virtual }}} {{{#LightSeaGreen,#DarkTurquoise 반환 자료형 }}}{{{#DarkOrange 멤버 함수 식별자 }}}( 매개변수'') {{{#DodgerBlue,#CornFlowerBlue override '''}}}; }; |
가상 함수는 언제나 파생된 멤버 함수를 사용하도록 해주는 기능이다. 파생 클래스에서 기반 클래스의 멤버 함수를 재정의하려면 기반 클래스에는
virtual [반환형] [함수명] (매개 변수)
라고 자식에게 상속할 멤버 함수의 원형을 정의한다. 파생 클래스에서는 [반환형] [파생 클래스의 멤버 함수명] ([해당 함수의 멤버 변수]) override
[21]라고 원형을 정의한다. 이때 기반 클래스의 virtual
함수를 바로 가상 함수라고 한다. C++에서는 오직 가상 함수만 동적 바인딩을 지원한다.- <C++ 예제 보기>
#!syntax cpp import <string>; import <print>; class MyParent { public: virtual void print() { std::println("{}", text); } protected: std::string text = "Hello World"; }; class MyChild : public MyParent { public: virtual void print() override { std::println("{}", text); } private: std::string text = "안녕하세요."; };
MyParent
의 멤버 함수인 print()
함수에서는 멤버 변수인 text
의 내용인 Hello World
를 출력한다. 그러나 파생 클래스인 My Child
에서는 멤버 변수인 str
의 내용인 안녕하세요.
를 출력하도록 print()
함수를 재정의하였다.14.3.2. 추상 멤버 함수
class DerivedClass : access-specifier BaseClass { public: {{{#DodgerBlue,#CornFlowerBlue ' virtual '}}} 반환 자료형 멤버 함수 식별자 ( 매개변수) = 0; }; |
14.3.3. 가상 소멸자
class Class { public: virtual ~Class (); }; |
virtual
로 지정하면 소멸자를 동적 바인딩하므로 이런 문제를 방지할 수 있다.#!syntax cpp
template<size_t Capacity>
class MyParent20
{
public:
static constexpr size_t myCapacity = Capacity;
constexpr MyParent20() noexcept
: myBuffer(new char[Capacity]), myCapacity(Capacity)
{}
virtual constexpr ~MyParent20() noexcept
{
delete[] myBuffer;
}
virtual constexpr size_t GetCapacity() const noexcept
{
return myCapacity;
}
protected:
char* myBuffer;
};
template<size_t Capacity, size_t InnerCapacity>
class MyClass20 : public MyParent20<InnerCapacity>
{
public:
static constexpr size_t myCapacity = Capacity;
constexpr MyClass20() noexcept // 부모의 생성자는 명시해줘야 한다.
: MyParent20()
, childBuffer(new char[Capacity])
{}
// 파괴될 때 MyParent20::~MyParent20()이 실행된다.
constexpr ~MyClass20() noexcept(std::is_nothrow_destructible_v<std::string>) // noexcept(true)
{}
constexpr size_t GetCapacity() const noexcept override
{
return myCapacity;
}
};
private:
std::string childBuffer;
};
C++20부터는 constexpr
역시 사용할 수 있다. 그리고 virtual constexpr
이 가능해졌다.14.3.4. final
14.3.4.1. 클래스
class SealedClass final ; |
sealed
라는 이름으로 되어 있다14.3.4.2. 가상 멤버 함수
class DerivedClass : access-specifier BaseClass { public: virtual 반환 자료형 멤버 함수 식별자 ( 매개변수) override final ; }; |
14.3.5. 가상 상속
class DerivedClass : virtual {{{#DodgerBlue,#CornFlowerBlue 'access-specifier1 }}}'' Mixin1 , {{{#DodgerBlue,#CornFlowerBlue virtual }}} ''{{{#DodgerBlue,#CornFlowerBlue access-specifier2 }}}'' Mixin2 , {{{#DodgerBlue,#CornFlowerBlue virtual }}} ''{{{#DodgerBlue,#CornFlowerBlue access-specifier3 '}}} Mixin3 , ... ; |
- <C++ 예제 보기>
#!syntax cpp class Base { public: Base() : value(0) {} Base(int n) : value(n) {} int value; }; class A : public virtual Base { public: using Base::Base; }; class B : public virtual Base { public: using Base::Base; }; class C : public Base { public: using Base::Base; }; class S : public A, public B, public C { public: S() : A(1), B(2), C(3) {} void SetAValue(int n) { A::value = n; } void SetBValue(int n) { B::value = n; } void SetCValue(int n) { C::value = n; } int GetAValue(int n) { return A::value; } int GetBValue(int n) { return B::value; } int GetCValue(int n) { return C::value; } }; class T : public A, public B { public: S() : Base(1), A(2), B(3) // `A`, `B` 둘 다 가상 상속이라 가능하다. 이때 A(2), B(3)은 Base(1)보다 먼저 실행된다 {} void SetAValue(int n) { A::value = n; } void SetBValue(int n) { B::value = n; } int GetAValue(int n) { return A::value; } int GetBValue(int n) { return B::value; } }; int main() { S instance_s{}; T instance_t{}; // (1) // 모든 가상 상속 구조의 생성자 호출은 비가상 생성자 뒤에 이루어진다 // result_0 == 3 int result_0 = instance_s.value; // (2) // 모든 가상 상속 구조의 생성자 호출은 비가상 생성자 뒤에 이루어진다 // result_1 == 1 int result_1 = instance_t.value; // (3) // result_2_a == 2 // result_2_b == 2 // result_2_c == 3 int result_2_a = instance_s.GetAValue(); int result_2_b = instance_s.GetBValue(); int result_2_c = instance_s.GetCValue(); // (4) // result_3_a == 1 // result_3_b == 1 int result_3_a = instance_t.GetAValue(); int result_3_b = instance_t.GetBValue(); // (5) // result_4_a == 10 // result_4_b == 10 instance_s.SetA(10); int result_4_a = instance_s.GetAValue(); int result_4_b = instance_s.GetBValue(); // (6) // result_5_a == 30 // result_5_b == 30 instance_s.SetB(30); int result_5_a = instance_s.GetAValue(); int result_5_b = instance_s.GetBValue(); // (7) // result_6_a == 30 // result_6_b == 30 // result_6_c == 50 instance_s.SetB(50); int result_6_a = instance_s.GetAValue(); int result_6_b = instance_s.GetBValue(); int result_6_c = instance_s.GetCValue(); }
15. 적법함
적법함 (Eligible)앞서 복사와 이동에 대해 알아보았다. 클래스의 데이터 조작 방법에 복사 생성자, 이동 생성자, 복사 대입 연산자, 이동 대입 연산자가 있음도 알아보았다. 그리고 이 넷을 제어하는 방법도 알아보았다. 클래스는 이 네가지 특수 멤버 함수를 사용해서 데이터 흐름을 제어할 수 있다. 그러면 이 제어할 수 있다는 건 무슨 뜻일까? 당연히 해당하는 생성자와 대입 연산자가 존재한다는 뜻이다. 다시 이 존재한다는 건 무슨 뜻일까? 정확히 말하자면 선언-정의가 있다는 뜻이다. 모든 특수 함수는 기본적으로 클래스 안에 선언되어 있다. 그리고 이들을 사용할 조건이 충족되면, 그제서야 컴파일 과정에서 정의가 생성되는 것이다.
정의가 생성된 멤버 함수가 있으면 클래스가 적법한 생성자 또는 적법한 대입 연산자를 가지고 있다고 말한다. 적법하다는 건 암시적으로 생성되었든,
default
이든, 사용자가 직접 정의했든 상관없다. 클래스가 해당하는 멤버 함수를 갖고 있기만 하면 적법하다고 인정한다. 그런데 앞서 살펴 봤듯이 이 넷을 항상 이용할 수 있는게 아니다. 비정적 데이터 멤버가 복사 가능해야 인스턴스를 복사할 수 있으며, 이동 가능해야 이동시킬 수 있다. 이게 안되면 적법한 생성자, 적법한 대입 연산자가 없다고 말한다.- <C++ 예제 보기>
#!syntax cpp class AAA { public: AAA(int n) : myValue(n) {} int myValue; }; class DDD { public: ~DDD() = delete; }; class DD { private: ~DD() {} }; class D20 // C++20 { public: ~D20() requires(false) = default; ~D20() requires(std::is_destructible<DDD>) = default; // std::is_destructible<DDD> == false ~D20() requires(std::is_nothrow_destructible<DD>) = default; // std::is_nothrow_destructible<DD> == false }; unsigned int AB; float AC; class A : public AAA // (1) 기본 생성자가 없는 클래스로부터 상속받음 // 기본 생성자: O // 소멸자: O // 복사 생성자: O // 이동 생성자: O , public DDD // (2) 소멸자가 삭제된 클래스로부터 상속받음 , public DD // (3) 소멸자에 접근할 수 없는 클래스로부터 상속받음 , public D20 // (4) 모든 소멸자 후보가 삭제된 클래스로부터 상속받음 // 기본 생성자: O // 소멸자: X // 복사 생성자: X // 이동 생성자: X { public: // (5) 기본 값이 없는 비정적 상수 데이터 멤버 // 기본 생성자: X // 소멸자: O // 복사 생성자: X // 이동 생성자: X const int myA; // (6) // 기본 생성자: O // 소멸자: O // 복사 생성자: X // 이동 생성자: X const int myDefaultA = 190; // (7) 기본 값을 할당한 비정적 데이터 멤버 // 기본 생성자: X // 소멸자: O // 복사 생성자: O // 이동 생성자: O double myG = 40.100; char myH{ 'x' }; // (8) 기본 값이 없는 lvalue 비정적 데이터 멤버 // 기본 생성자: X // 소멸자: O // 복사 생성자: X // 이동 생성자: X unsigned int& myB; // (9) 기본 값이 없는 rvalue 비정적 데이터 멤버 // 기본 생성자: X // 소멸자: O // 복사 생성자: X // 이동 생성자: O float&& myC; // (10) // 기본 생성자: O // 소멸자: O // 복사 생성자: X // 이동 생성자: X unsigned int& myDefaultB = AB; // (11) // 기본 생성자: O // 소멸자: O // 복사 생성자: X // 이동 생성자: O float&& myDefaultC = AC; // (12) 상수 밖에 없는 내포 결합체 // 기본 생성자: X // 소멸자: O // 복사 생성자: X // 이동 생성자: X union { const bool myD; const long long myE; const int myF; }; // (12*) // 기본 생성자: O // 소멸자: O // 복사 생성자: X // 이동 생성자: X union { std::monostate myMonostate; const bool myD; const long long myE; const int myF; }; };
15.1. 적법한 생성
적법한 기본 생성자 (Eligible Default Constructor)#기본 생성자는 비정적 데이터 멤버들이 기본값으로 초기화할 수 있을 때 암시적으로 생성된다.
적법한 기본 생성자가 자동으로 생성되지 못할 충분조건은 다음과 같다:
- 기본 생성자를
delete
했을 경우 - 잠재적 생성자 후보 중에 적법한 오버로딩 대상이 없는 경우 C++20 [22]
- 비정적 데이터 멤버 중에 기본 생성자가
delete
되었거나, 잠재적 생성자 후보 중에서 적법한 오버로딩 대상이 없어서 기본 생성자가 없는 인스턴스가 있을 경우 - 비정적 데이터 멤버 중에
delete
된 소멸자 혹은 기본 생성자에서는 권한때문에 접근하지 못하는 소멸자를 가진 인스턴스가 있을 경우 [23] - 비정적 데이터 멤버 중에 모든 비정적 데이터 멤버가 상수인
union
의 인스턴스가 있을 경우 - 클래스 정의에서 상수가 아닌 비정적 데이터 멤버에 기본값을 할당한 경우
- 클래스 정의에서 참조형이 아닌 비정적 데이터 멤버에 기본값을 할당한 경우
- 비정적 데이터 멤버 중에
delete
된 소멸자 혹은 권한때문에 접근하지 못하는 소멸자를 가진 인스턴스가 있을 경우
정리하자면 모든 데이터 멤버들이 적법한 기본 생성자를 갖고 있어야 한다. 비정적
const
, &
, &&
데이터 멤버들은 기본값이 필요하지만, 나머지 멤버들은 기본값을 할당하면 안된다. 소멸자는 언제나 사용할 수 있어야 한다. 사실 어려운 조건은 아니고 당연히 피해야 할 상황이다. 설령 암시적으로 생성된 기본 생성자가 아니더라도 이 조건들은 클래스를 사용하면 나타나면 안되는 상황들이다.- C++20 까지는
virtual
함수나 코루틴 함수가 아니고, GOTO 및try-catch
및 어셈블리 구문이 없으면constexpr
일 수 있다. - C++23 부터는 완화되어
try-catch
, 어셈블리 구문이 없으면constexpr
일 수 있다.
15.2. 적법한 파괴
적법한 소멸자 (Eligible Destructor)#기본 소멸자는 비정적 데이터 멤버들을 파괴할 수 있으면 암시적으로 생성된다. 암시적으로 생성되지 못한 경우 직접 구현해도 적법한 소멸자로 인정한다. 소멸자는 C++ 클래스의 핵심 기능으로써 항상 사용할 수 있음을 보장받아야 한다. 소멸자를 못 쓰는 경우 무언가 잘못된 상황인지 확인해야 한다.
적법한 소멸자가 자동으로 생성되지 못할 충분조건은 매우 적다:
- 소멸자를
delete
했을 경우 - 비정적 데이터 멤버 중에
delete
된 소멸자 혹은 권한때문에 접근하지 못하는 소멸자를 가진 인스턴스가 있을 경우 [24] - 잠재적 소멸자 후보 중에 적법한 오버로딩 대상이 없는 경우 C++20 [25]
- 가상 기반 클래스에서 상속 받지 않은 비정적 데이터 멤버 중에 결합체(Union)가 있는 경우
- 가상 소멸자인데
operator delete(Class*)
가delete
된 경우
union
정도 빼고는 아주 예외적인 상황이다. union
은 웬만하면 쓰지말고 표준 라이브러리의 std::variant
를 쓰자.- C++17 까지는
constexpr
일 수 없었다. 대신 자명한 소멸자가 암시적으로constexpr
일 수는 있었다. - C++20 부터는
try-catch
, 어셈블리 구문이 없으면constexpr
일 수 있다.
15.3. 적법한 복사
복사 생성자와 복사 대입 연산자는 비정적 데이터 멤버들이 복사할 수 있을 때 암시적으로 생성된다. 암시적으로 생성되지 못한 경우 직접 구현해도 적법한 복사 생성자와 복사 대입 연산자로 인정한다.적법한 복사 생성자가 자동으로 생성되지 못할 충분조건은 다음과 같다:
- 복사 생성자를
delete
했을 경우 - 이동 생성자 또는 이동 대입 연산자를 사용자가 직접 정의한 경우. 이 경우는 위에서 언급했었다.
- 잠재적 복사 생성자 후보 중에 적법한 오버로딩 대상이 없는 경우 C++20 [26]
- 비정적
&&
데이터 멤버가 있는 경우 - 비정적 데이터 멤버 중에
delete
된 소멸자 혹은 복사 생성자에서는 권한때문에 접근하지 못하는 소멸자를 가진 인스턴스가 있을 경우 [27]
- C++20 까지는
virtual
함수나 코루틴 함수가 아니고, GOTO 및try-catch
및 어셈블리 구문이 없으면constexpr
일 수 있다. - C++23 부터는 완화되어
try-catch
, 어셈블리 구문이 없으면constexpr
일 수 있다.
15.4. 적법한 이동
이동 생성자와 이동 대입 연산자는 비정적 데이터 멤버들을 이동시킬 수 있을 때 암시적으로 생성된다. 암시적으로 생성되지 못한 경우 직접 구현해도 적법한 이동 생성자와 이동 대입 연산자로 인정한다.적법한 이동 생성자가 자동으로 생성되지 못할 충분조건은 다음과 같다:
- 이동 생성자를
delete
했을 경우 - 비정적 데이터 멤버 중에
delete
된 소멸자 혹은 이동 생성자에서는 권한때문에 접근하지 못하는 소멸자를 가진 인스턴스가 있을 경우 [28]
- C++20 까지는
virtual
함수나 코루틴 함수가 아니고, GOTO 및try-catch
및 어셈블리 구문이 없으면constexpr
일 수 있다. - C++23 부터는 완화되어
try-catch
, 어셈블리 구문이 없으면constexpr
일 수 있다.
16. 자명함
#!syntax cpp
struct TrivialWithPrimitives
{
bool myBoolean;
char myChar;
short myShort;
unsigned short myUnsignedShort;
wchar_t myWideChar;
int myInteger;
unsigned int myUnsignedInteger;
long long myLongLong;
unsigned long long myUnsignedLongLong;
const char* myString;
void* myHandle;
};
자명함 (Trivial)C++11 자명하다는 건 수학적으로 해를 언제나 찾을 수 있으며 찾는 과정도 명백하다는 의미다. 어떤 존재가 자명하다는 것은 그 객체가 스스로 그리고 자연스럽게 존재한다는 말이다. C++에서는 객체가 자명하다는 표현을 쓴다. 곧 해당하는 객체는 C++ 코드 어디에서나 바로 정의될 수 있고, 시작과 끝이 있는 명백한 동작을 수행한다는 뜻이다. 어디에서나 정의된다는 것은 링크 시점, 컴파일 시점, 실행 시점 모든 경우를 말한다. 시작과 끝이 있는 명백한 동작은 결정론적인 코드를 말한다. 즉 객체에 처음 조건이 주어지면 유한한 알고리즘 과정을 통해 상수 시간에 결과를 알 수 있다는 말이다. 다시 말해서 객체가 자명하다는 건 객체의 선언, 정의, 실행이 상수 시간에 결정될 수 있음을 말하는 것이다. 즉 자명성은
constexpr
과 같은 C++의 핵심 기능인 상수 표현식을 가능케 만들어 주었다.C++에서 변수, 함수, 자료형(클래스)는 자명해질 수 있는 세 가지 객체 유형이다. C++11부터 변수와 함수는
constexpr
, noexcept
, 그리고 C++20에서는 consteval
을 도입해 자명성을 갖게 되었다. 하지만, 클래스는 단순히 이 지시자들을 멤버에 적용하는 것만으로는 자명하다고 볼 수 없다. 컴파일 시점에 데이터 멤버의 값이 결정되더라도, 멤버 함수의 동작이 결정론적일 것이라는 보장은 없으며, 반대의 경우도 마찬가지다. C++에서 클래스의 자명함을 평가할 때는 비정적 데이터 멤버에 초점을 맞춘다. 이는 정적 데이터 멤버가 외부에서 온 것이기 때문에, 클래스 자체에 대한 정보로 보기 어렵다는 점에서 비롯된다. 메타 데이터 역할을 하더라도, 정의를 위해선 외부 구현이 필요하다. 멤버 함수는 실행 시간에 수행되는 것이 주 목적이므로, 자명성 평가에서는 고려하지 않는다. 클래스의 어떤 동작이 O(1)
에 완료되어 성능상의 이점을 가져오더라도, 이는 자명함에 영향을 주지 않는다. 자명함은 초기 조건에서 결과가 도출됨을 의미하며, 결정론적 함수의 존재나 클래스의 특정 성공은 중요하지 않다. 클래스의 용도가 멤버 함수에만 국한되지 않기 때문이다! 따라서 클래스의 자명함은 기본 생성자, 소멸자, 복사 생성자와 복사 대입 연산자, 이동 생성자와 이동 대입 연산자의 여섯 가지가 제대로 정의되었는지로 판단한다. 모든 원시 자료형도 자명한 자료형으로 간주된다.16.1. 자명한 기본 생성자
Trivial Default Constructor자명한 기본 생성자를 갖기 위한 필요조건은 다음과 같다:
- 적법한 기본 생성자가 존재해야 한다.
- 암시적으로 정의되거나,
default
여야 한다. - 바로 상속받는(1계위) 기반 클래스들이 자명한 기본 생성자를 갖고 있어야 한다.
- 모든 비정적 데이터 멤버들이 자명한 기본 생성자를 갖고 있어야 한다.
- 비정적 데이터 멤버에 기본값을 할당해서는 안된다.C++11
- 소멸자를 포함해 가상 멤버 함수를 가질 수 없고 당연히 추상 클래스여서도 안된다.
- 기반 클래스 중에 가상 클래스가 있으면 안된다.
16.2. 자명한 소멸자
Trivial Destructor자명한 기본 소멸자를 갖기 위한 필요조건은 다음과 같다:
- 적법한 소멸자가 존재해야 한다.
- 암시적으로 정의되거나,
default
여야 한다. - 가상 소멸자이면 안된다.
- 바로 상속받는(1계위) 기반 클래스들이 자명한 소멸자를 갖고 있어야 한다.
- 모든 비정적 데이터 멤버들이 자명한 소멸자를 갖고 있어야 한다.
- 기반 클래스 중에 가상 클래스가 있으면 안된다.
16.3. 자명한 복사 생성자
Trivial Copy Constructor자명한 복사 생성자를 갖기 위한 필요조건은 다음과 같다:
- 적법한 복사 생성자가 존재해야 한다.
- 암시적으로 정의되거나,
default
여야 한다. - 바로 상속받는(1계위) 기반 클래스들이 자명한 복사 생성자를 갖고 있어야 한다.
- 모든 비정적 데이터 멤버들이 자명한 복사 생성자를 갖고 있어야 한다.
- 소멸자를 포함해 가상 멤버 함수를 가질 수 없고 당연히 추상 클래스여서도 안된다.
- 기반 클래스 중에 가상 클래스가 있으면 안된다.
16.4. 자명한 복사 대입 연산자
Trivial Copy Assignment Operator자명한 복사 대입 연산자를 갖기 위한 필요조건은 다음과 같다:
- 적법한 복사 대입 연산자가 존재해야 한다.
- 암시적으로 정의되거나,
default
여야 한다. - 바로 상속받는(1계위) 기반 클래스들이 자명한 복사 대입 연산자를 갖고 있어야 한다.
- 모든 비정적 데이터 멤버들이 자명한 복사 대입 연산자를 갖고 있어야 한다.
- 소멸자를 포함해 가상 멤버 함수를 가질 수 없고 당연히 추상 클래스여서도 안된다.
- 기반 클래스 중에 가상 클래스가 있으면 안된다.
16.5. 자명한 이동 생성자
Trivial Move Constructor자명한 이동 생성자를 갖기 위한 필요조건은 다음과 같다:
- 적법한 이동 생성자가 존재해야 한다.
- 암시적으로 정의되거나,
default
여야 한다. - 바로 상속받는(1계위) 기반 클래스들이 자명한 이동 생성자를 갖고 있어야 한다.
- 모든 비정적 데이터 멤버들이 자명한 이동 생성자를 갖고 있어야 한다.
- 소멸자를 포함해 가상 멤버 함수를 가질 수 없고 당연히 추상 클래스여서도 안된다.
- 기반 클래스 중에 가상 클래스가 있으면 안된다.
16.6. 자명한 이동 대입 연산자
Trivial Move Assignment Operator자명한 이동 대입 연산자를 갖기 위한 필요조건은 다음과 같다:
- 적법한 이동 대입 연산자가 존재해야 한다.
- 암시적으로 정의되거나,
default
여야 한다. - 바로 상속받는(1계위) 기반 클래스들이 자명한 이동 대입 연산자를 갖고 있어야 한다.
- 모든 비정적 데이터 멤버들이 자명한 이동 대입 연산자를 갖고 있어야 한다.
- 소멸자를 포함해 가상 멤버 함수를 가질 수 없고 당연히 추상 클래스여서도 안된다.
- 기반 클래스 중에 가상 클래스가 있으면 안된다.
16.7. 자명하게 복사 가능한 클래스
자명하게 복사 가능 (Trivially Copyable)C++11자명하게 복사 가능한 클래스는 내부 데이터 멤버가 잘 정의된 클래스를 말한다. 비정적 참조형 데이터 멤버가 없으며 모든 비정적 데이터 멤버는 자명한 값을 가지고 있다. 곧 모든 클래스의 정보가 외부로부터 의존하지 않으며 클래스 스스로 값을 가지고 있다. 그래서
std::memcpy
, std::memmov
, 혹은 기타 바이트 변환 함수를 사용해서 모든 클래스의 정보를 상수 시간에 직렬화할 수 있다.자명한 클래스가 되기 위한 필요조건은 다음과 같다:
- 적법한 복사 생성자, 복사 대입 연산자, 이동 생성자, 이동 대입 연산자 중 하나 이상을 갖고 있어야 한다.
- 모든 적법한 복사 생성자, 복사 대입 연산자, 이동 생성자, 이동 대입 연산자는 암시적으로 정의되거나,
default
여야 한다. delete
되지 않은 자명한 소멸자를 갖고 있어야 한다.
16.8. 자명한 클래스
자명한 클래스 (Trivial Class)C++11자명한 클래스는 C++ 코드 어디에서나, 자연스럽게 존재하는, 스스로 존재하는 클래스라는 뜻이다. 자명한 클래스는 다른 클래스에 의존하지 않고, 참조하지도 않는다. 또한 어떠한 문맥에도 의존하지 않는다. 클래스 자명성의 의의는 이 클래스의 존재, 생성, 파괴가 컴파일 시점에 결정될 수 있다는 것이다. 즉, 어떠한 부작용(Side Effect) 없이, 결정론적인(Deterministic) 코드를 생성할 수 있다. 결정론적 코드 안에서 클래스는 데이터의 흐름을 결정하고 사용자는 그 결과를
O(1)
의 시간에 확인할 수 있다. 곧, constexpr
함수의 반환형, 인자로써, 그리고 함수 내부에서 생성하고 사용할 수 있다. 또한 템플릿의 매개변수로 직접 사용할 수 있다.자명한 클래스가 되기 위한 필요조건은 다음과 같다:
- 적법한 기본 생성자와 소멸자가 존재해야 하며, 자명해야 한다.
- 자명하게 복사 가능해야 한다.
17. 리터럴 클래스
리터럴 클래스 (Literal class)Literal TypeC++11리터럴 클래스가 되기 위한 필요조건은 다음과 같다:
- 자명한 클래스여야 한다.
- 모든 적법한 생성자와 적법한 소멸자와 적법한 복사 생성자와 적법한 이동 생성자와 적법한 복사 대입 연산자와 적법한 이동 대입 연산자가
constexpr
여야 한다.
18. 표준 규격 클래스
표준 규격 클래스 (Standary layout class)C++11표준 규격이란 C언어의 자료구조 규격을 말한다. 이 규칙을 만족하는 클래스는 C언어의 구조체와 완전히 호환된다는 뜻이다. 직렬화가 용이하다. 다형성, 상속 기능을 안쓰면 만족시키기는 어렵지 않다.
표준 규격 클래스가 되기 위한 필요조건은 다음과 같다:
- 첫번째로 놓인 비정적 데이터 멤버의 자료형이 기반 클래스이면 안된다.
- 표준 규격이 아닌 자료형 및 표준 규격이 아닌 자료형의 배열을 비정적 데이터 멤버로 가지면 안된다.
- C++ 객체가 아닌 자료형의 비정적 데이터 멤버를 가지면 안된다. 즉 참조형 비정적 데이터 멤버는 가질 수 없다.
- 모든 비정적 데이터 멤버는 같은 접근 권한 하에 있어야 한다.
- 소멸자를 포함해 가상 멤버 함수를 가질 수 없고 당연히 추상 클래스여서도 안된다.
- 기반 클래스 중에 가상 클래스가 있으면 안된다.
- 기반 클래스 중에 비정적 데이터 멤버를 가진 클래스는 오직 한 개 이하여야 한다.
- 모든 기반 클래스가 상기한 표준 규격을 만족하는 클래스여야만 한다.
19. 일렬 데이터 클래스
일렬 데이터 클래스 (POD, Plain Old Data)C++20에서 표준에서 deprecated 처분을 받았다.
[비공개] [공개] [3] C언어의 구조체는 기본적으로
public
이 적용돼있다, 클래스의 만든 멤버들을 바로 사용할 수 없는 이유가 바로 접근 권한 때문이다. 클래스 안의 멤버들은 기본적으로 숨겨져 있기 때문에 작성자가 직접 접근 권한을 설정해줘야 한다.[보호됨] [구두점] [화살표] [범위] 확인[8] 보통은 0이다[9] 이런 함수를 공장(Factory) 함수라고 부른다[10] 자료형문서에서 봤듯이 this는 변수가 아니라 이름이 없는 임시값인 prvalue다[11] 함수 명칭을 클래스 이름과 같게 두고, 반환형을 명시하지 않는다[12] 이외에는 스크립트 언어, C#, 파이썬 정도나 제한적으로 연산자 오버로딩을 지원하는 상황이다[13] 연산자는 friend가 아니면 static일 수 없다[14] Jetbrain의 IDE을 쓰면 알려준다[15] 첫번째는 std::exception, 두번째는 코루틴[16] 즉 연산자가 다른 곳에 구현되어 있으면[protected] [protected] [protected] [public] [21] virtual을 붙일지 말지는 자유다[22] C++20부터 제약조건의 추가로 소멸자 여러개를 만들어두고 requires 구문으로 선택해서 실행할 수 있다[23] 소멸자[24] 소멸자[25] C++20부터 제약조건의 추가로 소멸자 여러개를 만들어두고 requires 구문으로 선택해서 실행할 수 있다[26] C++20부터 제약조건의 추가로 복사 생성자 여러개를 만들어두고 requires 구문으로 선택해서 실행할 수 있다[27] 소멸자[28] 소멸자