9.1 프렌드
- c++는 클래스 안에서 다른 클래스, 다른 클래스의 멤버 함수 또는 비 멤버 함수를 프렌드로 선언하는 기능을 제공한다. 프렌드로 선언한 대상은 이 클래스의 protected나 private 데이터 멤버와 메서드에 접근할 수 있습니다.
- 특정한 메서드만 프렌드로 만들 수도 있다.
- 프렌드로 지정할 클래스, 메서드, 함수는 반드시 접근을 허용할 클래스에서 지정해야 한다. 이들을 대상 클래스가 아닌 다른 곳에서 프렌드라고 선언해서 그 클래스의 private이나 protected 멤버에 접근하게 할 수는 없다.
- 클래스나 메서드를 프렌드로 지정하는 기능을 남용하지 않도록 주의한다. 프렌드 기능은 클래스 내부를 다른 클래스나 함수에 드러내기 때문에 캡슐화 원칙에 위배될 수 있다. 따라서 꼭 필요한 경우에만 사용한다.
9.2 객체에 동적 메모리 할당하기
- 객체에서 동적으로 할당한 메모리는 메모리 해제, 객체 복제 처리, 객체 대입 연산 처리 등과 같은 점에 주의해야 한다.
9.2.1 Spreadsheet 클래스
9.2.2 소멸자로 메모리 해제하기
- 동적으로 할당한 메모리를 다 썼다면 반드시 해제해야 한다. 객체 안에서 동적으로 할당한 메모리를 해제하는 작업은 그 객체의 소멸자에서 처리하도록 작성한다. 그러면 컴파일러는 객체가 제거될 때 소멸자를 호출하게 해준다.
- 소멸자는 클래스와 이름이 같고 틸드(~) 기호가 앞에 붙는다.
- 소멸자는 인수를 받지 않으며 생성자와 달리 단 하나만 존재한다.
- 소멸자는 익셉션을 던지면 안된다.
- 소멸자에서는 메모리를 해제하거나 다른 리소스를 반환하는 코드만 작성하는 것이 바람직하다.
9.2.3 복제와 대입 처리하기
- 복제 생성자나 대입 연산자를 직접 정의하지 않으면 컴파일러가 자동으로 만들어준다. 이렇게 컴파일러에서 생성된 메서드는 객체 타입 데이터 멤버에 대해 복제 생성자나 대입 연산자를 재귀적으로 호출한다.
- 하지만 int, double, 포인터와 같은 기본 타입에 대해서는 비트 단위 복제(또는 얕은 복제)나 대입이 적용된다. 즉 원본 객체의 데이터 멤버를 대상 객체로 단순히 복제하거나 대입하기만 한다.
- 그런데 객체에 동적으로 할당한 메모리가 있으면 문제가 발생한다. 이는 포인터의 복제본만 받아 동일한 데이터를 가리키는 포인터가 되어버린다. 따라서 값을 변경하면 동일하게 적용되는 문제가 발생한다. 또는 한쪽에서 해제하면 다른 곳에서는 해제된 메모리를 가리키게 되며 이를 댕글링 포인터라 부른다.
- 기존에 가르키던 메모리의 주소를 해제하지 않고 덮어버리면 메모리는 미아가 된다. 이러한 상황을 메모리 누수라 한다.
- 그러므로 복제 생성자와 대입 연산자는 반드시 깊은 복제를 적용해야 한다. 즉 복사 포인터 데이터 멤버뿐만 아니라 이러한 포인터가 가리키는 실제 데이터를 복사해야 한다.
- 클래스에 동적 할당 메모리가 있다면 이를 깊은 복제로 처리하도록 복제 생성자와 대입 연산자를 직접 정의해야 한다.
+ 1. Spreadsheet 복제 생성자
...
+ 2. Spreadsheet 대입 연산자
- 익셉션이 발생해도 문제가 발생하지 않도록 대입 연산자를 구현하려면 복제 후 맞바꾸기 구문을 적용하는 것이 좋다.
- 복제 후 맞바꾸기 구문을 익셉션에 안전하게 구현하려면 swap() 함수에서 절대로 익셉션을 던지면 안 된다. 따라서 noexcept로 지정한다.
- 아무런 익셉션도 던지지 않는 함수 앞에는 noexcept 키워드를 붙인다.
- noexcept로 지정한 함수에서 익셉션을 던지면 프로그램이 멈춘다.
- swap() 함수에서 실제로 데이터 멤버를 교체하는 작업은 표준 라이브러리의 <utility>에서 제공하는 유틸리티 함수인 std::swap()으로 처리한다.
- 오른쪽 항의 복제 버전인 temp를 만든 뒤 현재 객체와 맞바꾼다. 대입 연산자를 구현할 때는 이 패턴에 따르는 것이 바람직하다. 그래야 익셉션에 대한 안전성을 높일 수 있다. 다시 말해 익셉션이 발생하더라도 객체는 변하지 않는다. 이렇게 구현하는 과정은 다음 세 단계로 구성된다.
- 1 단계 : 임시 볷제본을 만든다. 이렇게 해도 객체의 상태가 변경되지 않는다. 따라서 이 과정에서 익셉션이 발생해도 문제가 되지 않는다.
- 2 단계 : swap() 함수를 이용하여 현재 객체를 생성된 임시 복제본으로 교체한다. swap() 함수는 익셉션을 전혀 던지지 않는다.
- 3 단계 : swap()으로 인해 원본 객체를 담고 있는 임시 객체를 제거하여 메모리를 정리한다.
- 대입 연산자를 구현할 때 코드 중복을 방지하고 익셉션 안전성을 높이도록 복제 후 맞바꾸기 구문을 적용한다.
- 복제 후 맞바꾸기 구문은 대입 연산자 외에도 적용 가능하다. 연산이 여러 단계로 구성되어 있을 때 모두 정상적으로 처리하거나 중간에 문제가 생기면 아무 것도 하지 않아야 하는 연산이라면 어디든지 적용할 수 있다.
+ 3. 대입과 값 전달 방식 금지
- 때로는 클래스에서 메모리를 동적으로 할당할 때 아무도 그 클래스의 객체에 복제나 대입을 할 수 없게 만드는 게 가장 간편한 경우가 있다. 이렇게 하려면 operator=과 복제 생성자를 명시적으로 삭제하면 된다.
9.2.4 이동 의미론으로 이동 처리하기
- 객체에 이동 의미론을 적용하려면 이동 생성자와 이동 대입 연산자를 정의해야 한다. 그러면 컴파일러는 원본 객체가 임시 객체로 되어 있어서 연산을 수행한 후 자동으로 제거되거나 사용자가 명시적으로 std::move()를 호출하여 삭제될 때 앞서 정의한 이동 생성자와 이동 대입 연산자를 이용한다.
- 즉, 메모리를 비롯한 리소스의 소유권을 다른 객체로 이동시킨다. 이 과정은 멤버 변수에 대한 얕은 복제와 비슷하다.
- 또한 할당된 메모리나 다른 리소스에 대한 소유권을 전환함으로써 댕글링 포인터나 메모리 누수를 방지한다.
- 이동 생성자와 이동 대입 연산자는 원본 객체에 있는 데이터 멤버를 새 객체로 이동시키기 때문에 그 후 원본 객체는 정상이긴 하나 미확정된 상태로 남게 된다. 흔히 이러한 원본 객체의 데이터 멤버를 널값으로 초기화하지만 꼭 그래야 하는 것은 아니다. 안전을 생각하면 이동되고 남은 객체를 사용하지 않는 것이 좋다.
- 단, unique_ptr과 shared_ptr은 예외다. 표준 라이브러리는 이러한 스마트 포인터를 이동하고 나서 반드시 내부적으로 nullptr로 초기화하도록 명시하고 있다.
+ 1. 우측값 레퍼런스
- c++에서 말하는 좌측값(엘벨류Lvalue)이란 이름 있는 변수처럼 주소를 가질 수 있는 대상을 가리킨다. 좌측값이라고 부르는 이유는 대입문의 왼쪽에 나오기 때문이다.
- 이름 있는 변수는 좌측값이다.
- 우측값(알밸류Rvalue)은 리터럴, 임시 객체, 값처럼 좌측값이 아닌 나머지를 가리킨다. 일반적으로 우측값은 대입문의 오른쪽에 나온다. 우측값은 대부분 임시값이라 문장을 실행하고 나면 제거된다.
- 우측값 레퍼런스란 개념도 있다. 말 그대로 우측값에 대한 레퍼런스다. 특히 우측 값이 임시 객체이거나 std::move()로 명시적으로 이동된 객체일 때 적용된다. 우측값 레퍼런스는 오버로딩된 여러 함수 중에서 우측값에 대해 적용할 대상을 결정하는 데 사용된다.
- 우측값 레퍼런스로 구현하면 크기가 큰 값(객체)을 복사하는 연산이 나오더라도 이 값이 나중에 삭제될 임시 객체라는 점을 이용하여 그 값에 우측값에 대한 포인터를 복사하는 방식으로 처리할 수 있다.
- 함수의 매개변수에 &&를 붙여서 우측값 레퍼런스로 만들 수 있다(예: type&& name). 일반적으로 임시 객체는 const type&로 취급하지만 함수의 오버로딩 버전 중에서 우측값 레퍼런스를 사용하는 것이 있다면 그 버전으로 임시 객체를 처리한다.
void handleMessage(string& message) // 좌측값 레퍼런스 매개변수
{
....
}
void handleMessage(string&& message) // 우측값 레퍼런스 매개변수
{
....
}
string a { "Hello" };
handleMessage(a); // void handleMessage(string& message)를 호출
string b { "World" };
handleMessage(a + b); // void handleMessage(string&& message)를 호출
handleMessage("Hello World"); // void handleMessage(string&& message)를 호출
- 좌측값 인수와 우측값 레퍼런스 타입의 매개변수를 바인딩할 수 없다. 이럴 때는 std::move()를 사용하여 컴파일러에 강제로 우측값 레퍼런스 버전의 handleMessage()를 호출하도록 만들 수 있다. move()는 좌측값을 우측값 레퍼런스로 캐스트해주기만 한다. 다시 말해 실제로 이동시키는 작업은 전혀 하지 않는다. 하지만 우측값 레퍼런스로 리턴하게 되면 컴파일러는 handleMessage()에 대한 여러 가지 오버로드 버전 중에서도 이동 작업을 처리할 수 있는, 우측값 레퍼런스를 인수로 받는 것을 찾는다.
handleMessage(std::move(b)); // handleMessage(string&& value)를 호출
- 타입이 우측값 레퍼런스인 매개변수를 다른 함수에 우측값으로 전달하려면 std::move()를 이용하여 좌측값을 우측값 레퍼런스로 캐스팅해야 한다.
- 우측값 레퍼런스 매개변수와 같이 이름 있는 우측값 레퍼런스는 타입이 우측값 레퍼런스일 뿐 이름이 있으므로 매개변수 자체는 좌측값인 점에 주의해야 한다.
- 우측값 레퍼런스는 함수의 매개변수 외에도 다양한 곳에서 사용한다. 예를 들어 변수를 우측값 레퍼런스 타입으로 선언한 뒤 값을 할당할 수도 있다.
int& i { 2 }; // 에러 : 상수에 대한 레퍼런스
int a { 2 }, b { 3 };
int& j { a + b }; // 에러 : 임시 객체에 대한 레퍼런스
// 우측값 레퍼런스를 사용해서 해결
int&& i { 2 };
int a { 2 }, b { 3 };
int&& j { a + b };
- 우측값 레퍼런스에 임시값을 대입하면 우측값 레퍼런스가 스코프에 있는 동안 계속 존재할 수 있다. 즉 수명이 연장되는 효과가 발생한다.
+ 2. 이동 의미론 구현 방법
- 이동 의미론은 우측값 레퍼런스로 구현한다. 클래스에 이동 의미론을 추가하려면 이동 생성자와 이동 대입 연산자를 구현해야 한다. 이때 이동 생성자와 이동 대입 연산자를 noexcept로 지정해서 두 메서드에서 익셉션을 절대로 던지지 않는다고 컴파일러에게 알려줘야 한다. 특히 표준 라이브러리와 호환성을 유지하려면 반드시 이렇게 해야 한다. 표준 라이브러리 컨테이너의 완벽한 호환성 구현은 이동 의미론을 구현하고 익셉션도 던지지 않는다고 보장해야 저장된 객체를 이동시키기 대문이다.
- 이동 생성자와 이동 대입 연산자는 멤버 변수에 대한 메모리 소유권을 원본 객체에서 새로운 객체로 이동시킨다. 그리고 원본 객체의 소멸자가 이 메로리를 해제하지 않도록 원본 객체의 멤버 변수 포인터를 널 포인터로 리셋한다. 이 시점에서 그 메모리에 대한 소유권이 새 객체로 이동한 상태이기 때문이다.
- 이동 의미론은 원본 객체가 더 이상 필요 없어서 삭제할 때만 유용하다.
- 이동 생성자와 이동 대입 연산자도 명시적으로 삭제하거나 디폴트로 만들 수 있다.
- 사용자가 클래스에 복제 생성자, 복제 대입 연산자, 이동 대입 연산자, 소멸자를 직접 선언하지 않았다면 컴파일러가 디폴트 이동 생성자를 만들어준다.
- 또한 사용자가 클래스에 복제 생성자, 이동 생성자, 복제 대입 연산자, 소멸자를 직접 선언하지 않았다면 컴파일러는 디폴트 이동 대입 연산자를 만들어준다.
- 클래스에 소멸자, 복제 생성자, 이동 생성자, 복제 대입 연산자, 이동 대입 연산자 등과 같은 특수 멤버 함수를 하나 이상 선언했다면 일반적으로 이들 모두를 선언해야 한다. 이를 5의 법칙이라 부른다.
- 이들 모두를 구현하거나 =default나 =delete로 명시적으로 디폴트로 만들거나 삭제해야 한다.
| std::exchange()
- <utility>에 정의된 std::exchange()는 기존 값을 새 값으로 교체한 후 기존 값을 리턴한다.
- std::exchange()는 이동 대입 연산자를 구현할 때 유용하다. 이동 대입 연산자는 원본 객체에서 대상 객체로 데이터를 이동해야 한다. 이동 후에는 대부분 원본 객체에 있던 데이터를 널로 만든다.
| 객체 데이터 멤버 이동하기
- 데이터 멤버가 기본 타입으로 되어 있는 경우 직접 대입하는 방식으로 처리한다. 하지만 데이터 멤버가 객체일 때는 std::move()로 이동시켜야 한다.
| swap() 함수로 구현한 이동 생성자와 이동 대입 연산자
- 이동 생성자와 이동 대입 연산자를 swap() 함수로 구현하면, 코드가 줄어들고 수정이 용이해져 버그 발생 가능성이 줄어든다.
- 이동 생성자는 디폴트 생성자가 만든 *this를 원본 객체와 맞바꾼다. 마찬가지로 이동 대입 연산자도 *this와 rhs 객체를 맞바꾼다.
+ 3. Spreadsheet의 이동 연산자 테스트하기
- vector는 객체를 추가할 때마다 동적으로 커진다. 이렇게 할 수 있는 이유는 실행 중에 필요한 만큼 메모리를 추가로 할당해서 여기에 기존 vector에 있던 객체를 복제하거나 이동하기 때문이다. 이때 이동 생성자가 정의되어 있으면 컴파일러는 해당 객체를 복제하지 않고 이동시킨다. 이처럼 이동 방식으로 옮기기 때문에 깊은 복제를 수행할 필요가 없어서 훨씬 효율적이다.
+ 4. 이동 의미론으로 swap 함수 구현하기
- 이동 의미론으로 성능을 향상시킬 수 있는 또 다른 예로 두 객체를 스왑(맞바꾸기)하는 방법이 있다.
- 이때 복제하기에 상당히 무거우면 성능이 크게 떨어진다. 이럴 때는 다음과 같이 이동 의미론을 적용해서 복제가 발생하지 않도록 구현한다.
+ 5. return 문에서 std::move() 사용하기
- return object; 형식의 문장은 주어진 object가 로컬 변수거나, 함수에 대한 매개변수거나, 임시값이라면 우측값 표현식으로 취급하면서 리턴값 최적화(RVO)가 적용된다. 또한 object가 로컬 변수일 때 이름 있는 리턴값 최적화(NRVO)가 적용된다. RVO와 NRVO 둘 다 일종의 복제 생략으로서 함수에서 객체를 리턴하는 과정을 굉장히 효율적으로 처리한다. 복제 생략을 적용하면 컴파일러는 함수에서 리턴하는 객체를 복제하거나 이동시킬 필요가 없다. 이를 통해 영복제 값 전달 의미론을 구현할 수 있다.
- 객체를 리턴하는 데 std::move를 사용하면 어떻게 될까?
- 리턴문을 return object;나 return std::move(object);로 작성할 때 모두 컴파일러는 우측값 표현식으로 취급한다.
- 하지만 std::move()를 사용하면 컴파일러는 RVO나 NRVO를 적용하지 않는다. return object; 형식의 문장에만 적용되는 것이기 때문이다.
- 따라서 객체가 이동 의미론을 지원할 경우에는 컴파일러는 차선책으로 이동 의미론을 적용하고, 그렇지 않으면 복제 의미론을 적용하는데, 이렇게 되면 성능에 큰 타격을 입게 된다.
- 함수에서 로컬 변수나 매개변수를 리턴할 때는 std::move()를 사용하지 말고 그냥 return object;로 작성한다.
- RVO나 NRVO는 로컬 변수나 함수 매개변수에만 적용된다는 사실을 명심한다. 따라서 객체의 데이터 멤버를 리턴할 때는 RVO나 NRVO가 적용되지 않는다.
return condition ? object1 : object2;
- return object; 형식의 문장은 아니므로 컴파일러는 RVO나 NRVO를 적용하지 않고 복제 생성자를 이용하여 object1이나 object2 중에서 하나를 리턴한다. 적용하려면 아래와 같이 고쳐쓰면 된다.
if(condition) {
return object1;
} else {
return object2;
}
+ 6. 함수에 인수를 전달하는 최적의 방법
- 함수 매개변수가 기본 타입이 아닐 경우에는 함수로 전달하는 인수가 불필요하게 복제되지 않도록 const 레퍼런스를 사용한다고 했다. 하지만 우측값이 섞인 경우에는 좀 다르다. 우측값 같은 경우 std::move()를 통해 복제하지 않고 데이터를 이동시킬 수 있다. 이렇게 하려면 오버로드 버전을 두 개 만들어야하는 번거로운 점이 있다.\
- 하나로만 해결하는 방법이 있는데, 게다가 값 전달 방식이다.
- 복제되지 않을 매개변수에 대해서는 여전히 const 레퍼런스로 전달해야 한다. 값 전달 방식은 함수 안에서 어차피 복제하게 될 매개변수에만 적합하다. 이럴 때는 값 전달 방식을 적용하는 것이 좌측값과 우측값 모두에 대해 가장 효율적이다. 좌측값이 전달되면 const 레퍼런스 매개변수와 마찬가지로 단 한 번만 복제된다. 또한 우측값이 전달될 경우에는 우측값 레퍼런스 매개변수처럼 복제가 전혀 발생하지 않는다.
- 내부적으로 복제하는 함수에 대해서는 매개변수를 값 전달 방식으로 처리하지만 해당 매개변수는 이동 의미론을 지원하는 경우에만 그렇게 한다. 나머지 경우는 const 레퍼런스 매개변수를 사용한다.
9.2.5 영의 규칙
- 5의 법칙을 설명하면서 다섯 가지 특수 멤버 함수(소멸자, 복제 생성자, 이동 생성자, 복제 대입 연산자, 이동 대입 연산자)를 구현하는 방법을 살펴봤다. 그런데 모던 c++에서는 영의 규칙(0의 규칙)이란 것도 추가되었다.
- 영의 규칙이란 앞서 언급한 다섯 가지 특수 멤버 함수를 구현할 필요가 없도록 클래스를 디자인해야 한다는 것이다.
- 그렇게 하려면 먼저 예전처럼 메모리를 동적으로 할당하지 말고 표준 라이브러리 컨테이너와 같은 최신 구문을 활용해야 한다. 예를 들어 이중 포인터 데이터 멤버 대신 vector<vector<...>>를 이용함으로서 메모리를 자동으로 관리하기 때문에 앞서 언급한 다섯 가지 특수 멤버 함수가 필요 없다.
+ 모던 c++에서는 영의 규칙을 따른다.
- 5의 법칙은 커스텀 RAII 클래스에만 적용해야 한다. RAII 클래스는 리소스에 대한 소유권을 받으며, 그 리소스를 해제하는 것도 적절한 시점에 처리한다.
9.3 메서드의 종류
9.3.1 static 메서드
- 데이터 멤버에서 그랬던 것처럼 메서드도 객체 단위가 아닌 클래스 단위로 적용되는 것이 있다. 이를 static(정적, 스태틱) 메서드라 부르며 데이터 멤버를 정의하는 단계에 함께 작성한다.
- static 메서드는 특정 객체에 대해 호출되지 않기 때문에 this 포인터를 가질 수 없으며 어떤 객체의 비 static 멤버에 접근하는 용도로 호출할 수 없다는 점을 명심해야 한다.
- static 메서드는 근본적으로 일반 함수와 비슷하지만 유일한 차이점은 클래스의 private static이나 protected static 멤버에 접근할 수 있다는 것이다.
- 참고로 타입이 같은 객체의 private 비 static이나 protectd 비 static 멤버를 static 메서드에 접근하게 하는 방법은 있다. 예를 들어 객체를 포인터나 레퍼런스 타입의 매개변수로 전달하면 된다.
- 같은 클래스에 있는 메서드끼리는 static 메서드를 일반 함수처럼 호출할 수 있다. 클래스 밖에서 호출할 때는 메서드 이름 앞에 스코프 지정 연산자(::)를 이용하여 클래스 이름을 붙여야 한다. 접근 제한 방식도 일반 메서드와 똑같다.
9.3.2 const 메서드
- const 객체란 값이 바뀌지 않는 객체를 말한다. 객체나 객체의 레퍼런스 또는 포인터에 const를 붙이면 그 객체의 데이터 멤버를 절대 변경하지 않는다고 보장하는 메서드만 호출할 수 있다.
- 어떤 메서드가 데이터 멤버를 변경하지 않는다고 보장하는 표시를 하려면 해당 메서드 앞에 const 키워드를 붙인다.
- const는 메서드 프로토타입의 일부분이기 때문에 메서드를 정의하는 구현 코드에서도 반드시 적어야 한다.
- 메서드에 const 키워드를 붙이면 그 메서드 안에서 각 데이터 멤버에 대한 const 레퍼런스를 가진 것처럼 작동한다.
- static 메서드는 const로 선언할 수 없다. 동어반복에 해당하기 때문이다. static 메서드는 애초에 클래스의 인스턴스를 가질 수 없으므로 인스턴스 내부의 값을 변경한다는 것 자체가 말이 안 된다.
- const로 선언하지 않은 객체에 대해서는 const 메서드와 비 const 메서드 둘 다 호출할 수 있다. 반면 객체를 const로 선언했다면 const 메서드만 호출할 수 있다.
- const 객체도 제거할 수 있으므로 소멸자도 호출될 수 있다. 하지만 소멸자를 const로 선언할 수는 없다.
+ 1. mutable 데이터 멤버
- 때로는 const로 정의한 메서드에서 객체의 데이터 멤버를 변경하는 경우가 있다. 변경하는 데이터가 사용자에게는 드러나지 않더라도 엄연히 수정 동작이기 때문에 이런 메서드를 const로 선언하면 컴파일 에러가 발생한다.
- 멤버 변수 앞에 mutable로 선언하면 const 메서드에서 변경해도 컴파일 에러가 발생하지 않는다.
9.3.3 메서드 오버로딩
- 메서드나 함수도 같은 이름으로 여러 개 정의할 수 있는데 이를 오버로딩이라 부른다. 물론 매개변수 타입이나 매개변수 개수는 서로 달라야 한다.
- 컴파일러는 매개변수 정보를 참고하여 어느 버전의 함수를 호출할지 결정한다. 이를 오버로딩 결정이라 부른다.
- c++에서는 메서드의 리턴 타입만 다른 형식의 오버로딩은 지원하지 않는다. 그 정보만으로는 호출할 메서드의 버전을 정확히 결정할 수 없는 경우가 많기 때문이다.
+ 1. const 기반 오버로딩
- const를 기준으로 메서드를 오버로딩할 수 있다. 예를 들어 두 메서드를 정의할 때 이름과 매개변수는 같지만 하나는 const로 선언하고 다른 하나는 const를 붙이지 않는다.
- 종종 const 오버로딩과 비 const 오버로딩의 구현 코드가 똑같은 경우가 많은데, 이러한 코드 중복을 피하려면 스콧 메이어가 제안한 const_cast() 패턴을 적용한다.
- 비 const 버전은 const 버전을 적절히 캐스팅해서 호출하는 방식으로 구현한다.
+ 2. 명시적으로 오버로딩 제거하기
- 오버로딩된 메서드를 명시적으로 삭제해서 특정한 인수에 대해서는 메서드를 호출하지 못하게 만들 수 있다.
- =delete 키워드를 통해 명시적으로 오버로딩 함수를 삭제해 사용 시 컴파일 에러가 발생한다.
+ 3. 참조 한정 메서드(ref-qualified method)
- 일반 클래스 메서드는 그 클래스의 임시 인스턴스나 정식 인스턴스에 대해 호출할 수 있다.
- 특정한 메서드를 호출할 수 있는 인스턴스의 종류(임시 인스턴스 또는 정식 인스턴스)를 명시적으로 지정할 수 있다. 해당 메서드에 참조 한정자를 붙이면 된다.
- 메서드를 정식 인스턴스에 대해서만 호출할 수 있게 만들려면 메서드 헤더 뒤에 & 한정자를 붙인다.
- 메서드를 임시 인스턴스에 대해서만 호출할 수 있게 만들려면 메서드 헤더 뒤에 && 한정자를 붙인다.
9.3.4 인라인 메서드
- c++는 메서드(또는 함수)를 호출하는 구문에서 실제로 코드 블록을 호출하도록 구현하지 않고, 해당 메서드(함수)를 호출하는 자리에 메서드 본문을 집어 넣도록 추천하는 기능을 제공한다. 이를 인라이닝이라 부르며, 이렇게 구현하도록 설정한 메서드를 인라인 메서드라 부른다.
- 인라인 메서드는 메서드 정의 코드에서 이름 앞에 inline 키워드를 붙이는 방식으로 지정한다.
- inline 키워드는 컴파일러에 단지 힌트를 주기만 할 뿐, 컴파일러가 볼 때 인라이닝하면 성능이 더 나빠질 것 같으면 그냥 무시할 수 있다.
- 인라인 메서드에 대한 정의는 이를 호출하는 소스 코드 파일에 있어야 한다. 따라서 인라인 메서드를 정의할 때는 그 메서드가 속한 클래스 정의가 있는 파일에 작성한다.
- 고급 C++ 컴파일러를 사용한다면 인라인 메서드 정의를 클래스 정의와 같은 파일에 작성하지 않아도 된다.
- 예를 들어 마이크로소프트 비주얼 c++는 링크 타임 코드 생성(LTCG) 기능을 지원하므로 inline으로 선언하지 않거나 헤더 파일에 정의하지 않아도 함수나 메서드의 크기가 작으면 자동으로 인라인으로 처리한다. GCC와 Clang도 비슷한 기능을 제공한다.
- c++20 모듈 밖에서는 메서드 정의 코드를 클래스 정의 코드에 직접 작성하면 그 메서드에 직접 inline 키워드를 붙이지 않더라도 내부적으로 인라인 메서드로 지정한다.
- c++20에서 모듈로부터 익스포트한 클래스는 그렇지 않다. 이런 메서드는 inline 키워드를 직접 붙여야 한다.
- 최신 컴파일러는 코드 블롯과 같은 몇 가지 기준에 따라 주어진 메서드나 함수에 대한 인라이닝 여부를 평사해서 효과가 적다면 인라이닝하지 않는다.
9.3.5 디폴트 인수
- 메서드 오버로딩과 비슷한 기능으로 디폴트 인수가 있다. 함수나 메서드의 프로토타입에 매개변수의 디폴트값(기본값)을 지정하는 기능이다.
- 디폴트 인수가 지정된 매개변수에 대해 사용자가 원하는 인수를 직접 지정하면 디폴트값을 무시한다. 반면 사용자가 인수를 지정하지 않으면 디폴트 값을 적용한다.
- 매개변수의 디폴트값을 지정할 때는 반드시 오른쪽 끝에 있는 매개변수부터 시작해서 중간에 건너뛰지 않고 연속적으로 나열해야 한다. 이렇게 하지 않으면 컴파일러가 중간에 빠진 인수에 대해 디폴트값을 맞출 수 없기 때문이다.
- 디폴트 인수는 함수, 메서드, 생성자에서 지정할 수 있다. 디폴트 인수는 메서드를 선언하는 코드에서 지정한다.
- 모든 매개변수에 대해 디폴트값이 지정된 생성자는 디폴트 생성자처럼 쓸 수 있다.
- 디폴트 생성자도 정의하고, 모든 매개변수에 디폴트값이 지정된 생성자도 정의하면 컴파일 에러가 발생한다.
9.4 데이터 멤버의 종류
9.4.1 static 데이터 멤버
- static 데이터 멤버는 객체가 아닌 클래스에 속한다. static 데이터 멤버는 자신이 속한 클래스 범위를 벗어날 수 없다는 점만 빼면 글로벌 변수와 비슷하다.
- static 클래스 멤버를 정의하면 이 멤버에 대한 공간을 할당하는 코드를 소스 파일에 작성해야 한다. 일반적으로 클래스 메서드는 소스 파일에 정의한다. 이때 선언과 동시에 곧바로 초기화해도 된다.
ex) sizet_t Spreadsheet::ms_counter = { 0 };
+ 1. 인라인 변수
- c++17부터 static 데이터 멤버도 인라인으로 선언할 수 있다. 그러면 소스 파일에 공간을 따로 할당하지 않아도 된다.
ex) static inline size_t ms_counter { 0 };
- 클래스에 이렇게 선언했다면 소스 파일에 공간을 할당하는 문장은 생략해도 된다.
+ 2. 클래스 메서드에서 static 데이터 멤버 접근하기
- 클래스 메서드 안에서는 static 데이터 멤버를 일반 데이터 멤버처럼 사용해도 된다.
+ 3. 메서드 밖에서 static 데이터 멤버 접근하기
- static 데이터 멤버에 대해서도 접근 제한자를 적용할 수 있다. private로 선언하면 클래스 메서드 밖에서 접근할 수 없다. public으로 선언하면 클래스 메서드 밖에서 접근할 수 있는데, 이때 변수 앞에 클래스이름::라는 스코프 지정 연산자를 붙여야 한다.
- 데이터 멤버는 public으로 선언한 게터나 세터로 접근하게 만들어야 한다. 따라서 static 데이터 멤버를 외부에서 접근하려면 static get/set 메서드를 사용하는 방식으로 구현한다.
9.4.2 const static 데이터 멤버
- 클래스에 정의된 데이터 멤버를 const로 선언하면 데이터 멤버가 생성되어 초기화된 후에는 변경할 수 없다. 특정 클래스에만 적용되는 상수(클래스 상수)를 정의할 때는 글로벌 상수로 선언하지 말고 반드시 static const(const static) 데이터 멤버로 선언한다.
- 정수 및 열거 타입 static const 데이터 멤버는 인라인 변수로 지정하지 않아도 클래스 정의 코드에서 정의하고 초기화할 수 있다.
9.4.3 레퍼런스 데이터 멤버
- 레퍼런스는 실제로 가리키는 대상 없이는 존재할 수 없으므로 생성자 초기자에서 값을 반드시 지정해야 한다.
- 레퍼런스는 한 번 초기화하고 나면 레퍼런스가 가리크는 객체를 변경할 수 없다. 대입 연산자로 레퍼런스에 값을 대입할 수 없다.
- 레퍼런스 데이터 멤버도 const로 지정할 수 있다. const 레퍼런스는 읽기 전용 참조로 값을 바꿀 수 없다.
9.5 중첩 클래스
- 클래스 정의에는 데이터 멤버와 멤버 함수뿐만 아니라 중첩 클래스, 구조체, 타입 앨리어스(typedef), 열거 타입(enum)도 선언할 수 있다.
- 클래스 안에서 선언한 모든 것은 해당 클래스의 스코프로 제한된다. 따라서 public으로 선언한 멤버를 클래스 외부에서 접근할 때는 스코프 지정 연산자를 붙여야 한다.
- 클래스 정의 안에서 다른 클래스를 정의할 수도 있다. 그러면 안에 정의한 클래스는 밖의 클래스의 일부분이 된다.
- 안에 정의한 클래스의 구현부를 구현할 때 앞에 밖의 클래스 이름 및 스코프 지정 연산자를 붙여줘야 한다.
- 스코프 지정 연산자를 붙이는 규칙은 외부 클래스 안에 있는 메서드의 리턴 타입에도 적용된다. 단 매개변수에는 적용되지 않는다.
- 외부 클래스에 내부 클래스를 선언만(전방 선언문만 적고) 하고 구체적인 정의 코드는 다로 작성할 수도 있다.
- 중첩 클래스도 일반 클래스와 똑같은 접근 제어 규칙이 적용된다.
- 중첩 클래스를 private이나 protected로 선언하면 중첩 클래스를 담고 있는 클래스에서만 접근할 수 있다.
class Outer {
private:
class PrivateInner {}; // private 중첩 클래스
public:
class PublicInner {}; // public 중첩 클래스
};
- 중첩 클래스는 이를 담고 있는 클래스의 protectd와 private 멤버를 모두 접근할 수 있는 반면 중첩 클래스를 담고 있는 클래스는 중첩 클래스의 public 멤버만 접근할 수 있다.
구분 | 접근 가능 대상 |
중첩 클래스 → 바깥 클래스 | 모든 멤버 (private, protected, public) |
바깥 클래스 → 중첩 클래스 | rivate, protected은 못 보고 public만 가능 |
9.6 클래스에 열거 타입 정의하기
- 열거 타입도 데이터 멤버로 만들 수 있다.
9.7 연산자 오버로딩
9.7.1 예제: SpreadsheetCell의 덧셈 구현
+ 1. 첫 번째 버전 : add 메서드
+ 2. 두 번째 버전 : operator+ 오버로딩
- c++는 덧셈 기호(+)를 자신이 정의한 클래스의 의미에 맞게 정의하는 덧셈 연산자(addition operator)를 지원한다.
- operator+에서 operator와 + 사이에 공백을 넣어도 된다. 그러므로 operator+를 쓰는 대신 operator +를 써도 된다. 다른 연산자도 마찬가지다.
- 또한 operator+의 리턴 타입도 마음껏 정할 수 있다. 하지만 놀람 최소화의 원칙(최소 놀람 원칙)에 따라 의미상 자연스러운 타입으로 지정하는 것이 좋다.
| 암묵적 변환
- 컴파일러가 주어진 타입과 정확히 일치하는 operator+만 찾지 않고 적합한 타입에 대한 operator+를 찾으려 하기 때문이다. 또한 operator+를 찾을 수 있도록 적합한 타입으로 변환한다. 또한 operator+를 찾을 수 있도록 적합한 타입으로 변환한다.
- 암묵적 변환을 위해 생성자를 선택하는 과정에서 성능이 떨어질 수 있다. 항상 임시 객체를 생성하기 때문이다. 임시 객체가 생성되지 않게 하려면 타입에 따라 함께 정의해야 한다.
+ 3. 세 번째 버전 : operator+를 글로벌 함수로 구현하기
- 암묵적 변환은 객체가 연산자의 왼쪽에 있을 때만 적용되고, 오른쪽에 있을 때는 적용할 수 없다. 이 상태로는 operator+를 일반 덧셈처럼 교환 법칙이 성립하게 만들 방법이 없다. 하지만 클래스에 정의했던 operator+를 글로벌 함수로 만들면 가능하다. 글로벌 함수는 특정 객체에 종속되지 않기 때문이다.
- 글로벌 함수로 정의하려면 먼저 연산자를 모듈 인터페이스 파일에서 선언하고 익스포트해야 한다.
9.7.2 산술 연산자 오버로딩
- 다른 산술 연산자를 오버로딩하는 방법도 operator+와 비슷하다. 가능하면 기존 연산자의 의미를 최대한 따르는 것이 좋다.
+ 1. 축약형 산술 연산자의 오버로딩
- c++는 기본 산술 연산자뿐만 아니라 축약형 연산자(+=, -= 등)도 제공한다. 축약형 산술 연산자에 대한 오버로딩은 별도로 구현해야 한다.
- 축약형 연산자는 왼쪽에 나오는 객체를 새로 생성하지 않고 기존 객체를 변경한다는 점에서 기본 연산자 오버로딩과 다르다. 또한 일반 대입 연산자처럼 수정된 객체에 대한 레퍼런스를 생성한다는 미묘한 차이다 있다.
- 축약형 산술 연산자의 왼쪽에는 반드시 객체가 나와야 한다. 따라서 글로벌 함수가 아닌 메서드로 구현해야 한다.
- 연산자에 대한 일반 버전과 축약 버전을 모두 정의할 때는 코드 중복을 피하도록 다음과 같이 축약형 버전을 기준으로 일반 버전을 구현하는 것이 좋다.
9.7.3 비교 연산자 오버로딩
- >, <, <=, >=, ==, !=과 같은 비교 연산자도 클래스에서 직접 정의하면 편하다.
- c++20부터 이런 연산자에 대해 변경된 사항이 있으며, 삼항 비교 연산자(<=>, 우주선 연산자)도 추가되었다.
- c++20 이전의 비교 연산자도 기본 산술 연산자와 마찬가지로 연산자의 왼쪽과 오른쪽 인수 모두에 대해 암묵적으로 변환하게 만들려면 글로벌 함수로 구현해야 한다.
- 비교 연산자는 항상 bool 타입 값을 리턴한다. 바꿀 수 있지만 바람직하지 않다.
- 클래스에 데이터 멤버가 많을 대는 데이터 멤버마다 비교 연산자를 구현하기가 번거로울 수 있다. 하지만 ==과 <를 구현했다면 이를 바탕으로 나머지 비교 연산자를 구현할 수 있다.
- c++20부터는 클래스에 비교 연산자를 지원하는 과정이 상당히 간경해졌다. 먼저 operator==을 글로벌 함수가 아닌 클래스의 멤버 함수로 구현하는 것을 권장한다. 또한 그 연산자의 결과를 무시하지 않도록 [[nodiscard]] 어트리뷰트를 붙여주는 것이 좋다. 이렇게만 해도 교환 법칙이 성립한다.
- [[nodiscard]] 어트리뷰트란? 함수나 타입 앞에 붙여서, 리턴값을 무시하면 경고를 발생시킨다.
- c++20 컴파일러는 10 == myCell과 같이 작성된 표현식을 myCell == 10으로 고쳐 쓰며, 이 부분에 대해 operator== 멤버 함수를 호출한다. 또한 c++20부터는 operator==을 구현하면 != 버전도 자동으로 만들어준다.
- c++20에서는 모든 비교 연산자를 구현할 때 오버로드 연산자 operator<=> 하나만 추가로 구현하면 된다. 클래스에 operator==과 operator<=>를 구현했다면 c++20 컴파일러는 여섯 가지 비교 연산자를 자동으로 만들어준다.
- c++20부터는 operator<=>를 구현하면 >, <, <=, >=에 대한 표현식을 <=>를 사용하도록 변환하는 방식으로 자동으로 지원한다.
- c++20 컴파일러는 ==이나 != 비교 연산에 대해서는 <=> 기준으로 변환하지 않는다. 이렇게 하는 이유는 성능 문제를 피하기 위해서인데, operator==을 명시적으로 구현하는 것이 <=>를 사용하는 것보다 훨씬 효율적인 경우가 많다.
+ 1. 컴파일러가 생성한 비교 연산자
- operator==과 operator<=>도 디폴트로 만들 수 있다. 그러면 컴파일러가 각 데이터 멤버를 차례대로 비교하는 코드를 자동으로 만들어준다.
- 또한 operator<=>를 명시적으로 디폴트로 만들면 디폴트 operator==도 컴파일러가 자동으로 만들어준다.
- operator<=>의 리턴 타입으로 auto를 지정할 수도 있다. 그러면 컴파일러는 데이터 멤버에 대한 <=> 연산 결과를 기반으로 리턴 타입을 추론한다. 클래스에 operator<=>를 지원하지 않는 데이터 멤버가 있다면 리턴 타입을 추론할 수 없기 때문에 리턴 타입을 strong_orderint, partial_ordering, weak_ordering 등으로 명시적으로 지정해야 한다.
- 컴파일러가 디폴트로 지정된 <=> 연산자를 생성할 수 있으려면 클래스에 있는 데이터 멤버가 모두 operator<=>를 지원하거나(이 경우는 리턴 타입을 auto로 지정할 수 있다) operator<나 operator==을 제공해야 한다(이 경우는 리턴 타입을 auto로 지정할 수 없다)
- c++20에서 원하는 클래스에 대해 여섯 가지 비교 연산자를 모두 제공하려면 다음과 같이 한다.
- 클래스에서 operator<=>를 디폴트로 지정할 수 있다면 그렇게 만드는 코드 한 줄만 적으면 된다. 경우에 따라 operator==도 명시적으로 디폴트로 지정해야 한다.
- 클래스에서 operator<=>를 디폴트로 지정할 수 없다면 operator==과 operator<=>를 오버로드하는 버전을 메서드로 구현한다.
- 나머지 비교 연산자는 직접 구현할 필요 없다.
9.7.4 연산자 오버로딩을 지원하는 타입 정의하기
- 여러분의 클래스를 사용하는 사람의 편의를 위해 연산자 오버로딩을 제공하라.
- 거의 모든 연산자를 오버로딩할 수 있으며, 스트림 추가 및 추출 연산자도 오버로딩하면 편하다.
9.8 안정된 인터페이스 만들기
9.8.1 인터페이스 클래스와 구현 클래스
- 비 public 메서드나 데이터 멤버를 클래스에 추가할 때마다 이 클래스를 사용하는 클라이언트 코드를 매번 다시 컴파일해야 하는 단점이 있다. 프로젝트가 클수록 이 작업에 대한 부담이 커진다.
- 다행히 인터페이스를 보다 간결하게 구성하고 구현 세부사항을 모두 숨겨서 인터페이스를 안정적으로 유지하는 방법이 있다. 대신 작성할 코드가 좀 늘어난다.
- 기본 원칙은 작성할 클래스마다 인터페이스 클래스와 구현 클래스를 따로 정의하는 것이다.
- 인터페이스 클래스는 구현 클래스와 똑같이 public 메서드를 제공하되 구현 클래스 객체에 대한 포인터를 갖는 데이터 멤버 하나만 정의힌다. 이를 핌플 이디엄 pimple idiom (핌플 구문) 또는 브릿지 패턴이라 부른다.
- 인터페이스 클래스 메서드는 단순히 구현 클래스 객체에 있는 동일한 메서드를 호출하기만 한다.
- 그러면 구현 코드가 변해도 public 메서드로 구성된 인터페이스 클래스는 영향을 받지 않는다. 따라서 다시 컴파일할 일이 줄어든다.
- 주의할 점은 인터페이스 클래스에 존재하는 유일한 데이터 멤버를 구현 클래스에 대한 포인터로 정의해야 제대로 효과를 발휘한다. 데이터 멤버가 포인터가 아닌 값 타입이면 구현 클래스가 변경될때마다 다시 컴파일해야 한다.
- 인터페이스 클래스를 안정적으로 구현하면 빌드 시간을 단축할 수 있다.
- 인터페이스와 구현을 분리하는 대신 추상 인터페이스, 즉 가상 메서드로만 구성된 인터페이스를 정의한 뒤 이를 구현하는 클래스를 따로 작성해도 된다.
9.9 정리
'Programming II > C++' 카테고리의 다른 글
[전문가를 위한 C++] CHAPTER11 C++의 까다롭고 유별난 부분 (0) | 2025.04.14 |
---|---|
[전문가를 위한 C++] CHAPTER10 상속 활용하기 (0) | 2025.04.14 |
[전문가를 위한 C++] CHAPTER8 클래스와 객체 이해 (0) | 2025.04.09 |
[전문가를 위한 C++] CHAPTER7 메모리 관리 (0) | 2025.04.05 |
[전문가를 위한 C++] CHAPTER6 재사용을 고려한 설계 (0) | 2025.04.05 |