이펙티브 C++
https://www.yes24.com/Product/Goods/17525589
페이지 169p ~ 182p
이펙티브 C++(169p ~ 182p)
요약
항목 24: 타입 변환이 모든 매개변수에 대해 적용되어야 한다면 비멤버 함수를 선언하자
- 클래스에서 암시적 타입 변환을 지원하는 것은 일반적으로 못된 생각이지만, 이 규칙에도 예외가 있다.
- 가장 흔한 예외 중 하나가 숫자 타입을 만드는 경우이다.
- 유리수와 관련된 클래스를 만들 때, 정수를 유리수로 암시적으로 변환하는 부분은 어색하지 않다.
- C++에서 기본으로 제공하는
int
→double
변환도 크게 다르지 않다고 보면 된다.
Rational
클래스는 이런 결정에 따라 만들기 시작한 클래스이다.
class Rational {
public:
// int에서 Rational로의 암시적 변환을 허용하기 위해 explicit 키워드 사용 안 함
Rational(int numerator = 0, int denominator = 1);
// 분자, 분모 접근 함수
int numerator() const;
int denominator() const;
private:
}
Rational
클래스는 유리수를 나타내는 클래스이기 때문에 수치 연산을 기본으로 지원하고 싶을 것이다.- 이를 어떤 식으로 지원하는 것이 좋을까?(멤버 함수, 비멤버 함수, 비멤버 프렌드 함수 등)
- 항목 23에서 어떤 클래스와 관련된 함수를 그 클래스의 멤버로 두는 것은 실질적인 객체 지향 원칙에서 벗어난다고 하였지만, 일단은
operator*
를 멤버 함수로 구현해보자.
class Rational {
public:
const Rational operator*(const Rational& rhs) const;
}
Rational oneEight(1, 8);
Rational oneHalf(1, 2);
Rational result = oneHalf * oneEight;
result = result * oneEight;
Raional
을 이용한 곱셈에는 문제가 없어 보이지만,int
와 같이 다른 숫자 기본타입과 연산을 하고 싶은 경우에는 불가능하다는 사실을 알 수 있다.(혼합수치 계산)
// 가능
result = oneHalf * 2;
result = oneHalf.operator*(2);
// explicit 생성자가 없기 때문에 다음과 같이 작동함
const Rational temp(2);
result = oneHalf * temp;
// error
result = 2 * oneHalf;
result = 2.operator*(oneHalf);
- C++의 컴파일러는 첫 번째 경우에 대해 다음과 같은 순서로 작동한다.
- 컴파일러는 개발자가
operatro*
함수의 매개변수int
를 넘겼으며, 해당 함수의 매개변수로Rational
객체를 필요로 한다는 것을 알고 있다. - 컴파일러는
Rational
객체의 생성자는 explicit으로 선언되어 있지 않기 때문에int
타입의 매개변수를 받아 암시적으로Rational
객체를 생성한다.
- 컴파일러는 개발자가
- 여기에서 에러가 발생하는 두 번째 경우는 다음과 같이 진행된다.
- 2라는 상수에는
Rational
타입에 대한operator*
연산자가 오버로딩이 안되어 있기 때문에 컴파일러는 비멤버 버전의operator*
를 찾는다.(유효 네임스페이스 혹은 전역) - 그러나 알맞은 오버로딩을 찾을 수 없어 에러가 발생한다.
- 2라는 상수에는
- 이 둘의 차이로 알 수 있는 사실은, 암시적 타입 변환에 대해 매개변수가 작동하려면 매개변수 리스트에 들어 있어야만 한다는 것이다.
- 다시 말해 호출되는 멤버 함수를 갖고 있는 객체에 해당하는 암시적 매개변수에는 암시적 변환이 먹히지 않는다.
- 일단 2라는 상수는 클래스가 아니기 때문에 멤버 함수를 가지고 있지 않으며, 암시적 타입 변환을 예상한다고 해도 2가 호출하는
operator*
함수에서는 2는 암시적 변환이 허용되지 않는 대상이다. - 그러니까
operator*
함수를 호출하는 주체는 암시적 변환이 허용되지 않는 것이다.
- 따라서 이런 경우에는
operator*
를 비멤버 함수로 만들어서 컴파일러가 모든 인자에 대해 암시적 타입 변환을 할 수 있도록 내버려 두어야 한다.
class Rational {}
const Rational operator*(const Rational& lhs, const Rational& rhs)
{
return Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator());
}
- 프렌드 함수가 아닌 비멤버 함수로 만들 경우, 클래스에서 제공해주는 public 인터페이스만을 사용하여 구현이 가능하기 때문에 보다 객체지향적인 설계라 할 수 있다.
- 프렌드 함수는 피할 수 있다면 피하는 것이 좋다.
항목 25: 예외를 던지지 않는 swap에 대한 지원도 생각해 보자
- swap은 두 객체의 값을 맞바꾸기 위한 함수이다. 표준 라이브러리에서 제공하는 swap은 다음과 같다.
namespace std {
template<typename T>
void swap(T& a, T& b)
{
T temp(a);
a = b;
b = temp;
}
}
- 표준에서 제공하는 swap은 복사만 제대로 지원하는 타입(복사 생성자, 복사 대입 연산자)이면 어떤 타입의 객체이든 맞바꾸기 동작을 수행해준다.
- 그러나 swap 한 번을 호출하면서 여러 번의 복사가 발생한다.(a → temp, b → a, temp → b, 3번)
- 따라서 복사하면 손해를 보는 타입(포인터가 주성분인 타입)에 대해서는
pimpl(pointer to implementation)
기법을 사용해보자.
class WidgetImpl {
public:
private:
int a, b, c;
std::vector<double> v;
}
// pimpl 관용구를 사용한 클래스
class Widget {
public:
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs)
{
*pImpl = *(rhs.pImpl);
}
private:
WidgetImpl* pImpl;
}
- 이렇게 만들어진
Widget
객체를 맞바꾸면 pImpl 포인터만 살짝 바꾸는 것 말고 할 일이 없다. - 그러나 표준 swap은
Widget
객체에 대한 맞바꾸기가 이렇게 간단하다는 것을 모른다. - 그래서 표준 swap에다가 해당 객체를 맞바꾸기 할 때에는 일반적인 방법 대신, 내부의 pImpl 포인터만 맞바꾸라고 알려주어야 한다.
namespace std {
// 컴파일 안 됨.(pImpl이 private이라 그럼)
// T가 Widget인 경우에 대해 특수화한 코드
template<>
void swap<Widget>(Widget& a, Widget& b)
{
swap(a.pImpl, b.pImpl);
}
}
- 함수의 시작부분에 있는
template
키워드를 통해 이 함수가 swap의 완전 템플릿 특수화(total template specialization) 함수라는 것을 컴파일러에게 알려 준다. <Widget>
은 T가 Widget인 경우를 특수화한 경우라고 알려 주는 부분이다.- 다시 말해, 타입에 무관한 swap 템플릿이 Widget에 적용될 때는 위의 함수 구현을 사용해야 한다.
- 일반적으로 std 네임스페이스의 구성요소는 함부로 변경할 수 없지만, 프로그래머가 직접 만든 타입에 대해 표준 템플릿을 완전 특수화하는 것은 허용이 된다.
- 이제 pImpl에 접근할 수 있는 public 인터페이스를 만들어 위 함수가 컴파일 되게 만들자.
class Widget {
public:
void swap(Widget& other)
{
using std::swap;
swap(pImpl, other.pImpl);
}
}
namespace std
{
template<>
void swap<Widget>(Widget& a, Widget& b)
{
a.swap(b);
}
}
- 위의 코드는 컴파일도 되며, 기존 STL 컨테이너와의 일관성도 유지하는 코드이다.
- 이제 다음과 같이 클래스 템플릿의 경우에 대해 생각해보자.
template<typename T>
class WidgetImpl {};
template<typename T>>
class Widget {};
namespace std
{
// 에러 발생
template<typename T>
void swap<Widget<T>>(Widget<T>& a, Widget<T>& b)
{
a.swap(b);
}
}
- 얼핏 보기에는 깔끔한 코드로 보이지만, C++의 기준에는 적법하지 않다.
- 우리가 요청한 부분은 함수 템플릿(std::swap)을 부분적으로 특수화(partial specialization)해 달라고 컴파일러에게 요청한 것인데, C++는 클래스 템플릿에 대해서는 부분 특수화를 허용하지만, 함수 템플릿에 대해서는 허용하지 않는다.
- 함수 템플릿을 부분적으로 특수화하고 싶을 때에는 오버로드 버전을 만들면 된다.
namespace std
{
// 에러 발생
template<typename T>
void swap(Widget<T>& a, Widget<T>& b)
{
a.swap(b);
}
}
- 일반적으로 함수 템플릿의 오버로딩은 별 문제가 없지만, std 네임스페이스는 특별한 네임스페이스이기 때문에 위의 코드는 유효하지 않다.
- std 내의 템플릿에 대한 완전 특수화는 가능하지만, std에 새로운 템플릿을 추가하는 것은 불가능하다.
- 클래스, 함수 어떤 것도 불가능하다.
- std에 들어가는 구성요소의 결정은 전적으로 C++ 표준화 위원회에 달려 있다.
- 따라서 멤버 swap을 호출하는 비멤버 swap을 선언해 놓되, 이 비멤버 함수를 std::swap의 특수화 버전이나 오버로딩 버전으로 선언하지만 않으면 된다.
namespace WidgetStuff
{
template<typename T>
class Widget {};
template<typename T>
void swap(Widget<T>& a, Widget<T>& b) { a.swap(b); }
}
- 이제는 어떤 코드가 두 Widget 객체에 대해 swap을 호출해도, 컴파일러는 C++의 이름 탐색 규칙에 의해 WidgetStuff 네임스페이스에서 Widget의 특수화 버전을 찾아낸다.
사용자 입장에서의 경우
- 이제 swap을 구현하는 입장이 아니라 swap을 사용해야 하는 경우에 대해 알아보자.
- 어떤 함수 템플릿을 만들고 있는데, 중간에 swap을 써서 두 객체의 값을 맞바꾸는 경우가 있다고 가정한다.
template<typename T>
void doSomething(T& obj1, T& ojb2)
{
swap(obj1, obj2);
}
- 이 경우 호출될 수 있는 swap에는 총 세 가지 경우가 있다.
- std에 있는 일반형: 반드시 존재
- std의 일반형을 특수화한 버전: 있을 수도 있고 없을 수도 있음
- T 타입 전용의 버전: 있거나 없거나 혹은 다른 네임스페이스에 있거나 없거나
- 이 상황에서 타입 T 전용 버전이 있으면 그것을 호출하고, 없으면 std의 일반 swap을 호출하게 만들고 싶다면 다음과 같이 작성해야 한다.
template<typename T>
void doSomething(T& obj1, T& ojb2)
{
using std::swap;
swap(obj1, obj2);
}
- 컴파일러가 위의 swap을 만났을 때 하는 일은 현재의 상황에 딱 맞는 swap을 찾는 것이다.
- C++의 이름 탐색 규칙을 따라, 우선 전역 유효범위 혹은 타입 T와 동일한 네임스페이스 안에 T 전용의 swap이 있는지를 찾는다.
- T가 WidgetStuff 네임스페이스 내의 Widget이라면, 컴파일러는 인자 의존 규칙을 적용하여 WidgetStuff의 swap을 찾아낼 것이다.
- T 전용 swap이 없으면 컴파일러는 그 다음 스텝을 밟는데,
using std::swap
이 함수 내부에 선언되어 있기 때문에 std의 swap을 선택한다.- 이런 상황에서도 std::swap의 T 전용 버전을 일반형 템플릿보다 더 우선 적용하기 떄문에 T에 대한 std::swap의 특수화 버전이 이미 준비되어 있으면 특수화 버전이 사용된다.
- C++의 이름 탐색 규칙을 따라, 우선 전역 유효범위 혹은 타입 T와 동일한 네임스페이스 안에 T 전용의 swap이 있는지를 찾는다.
메모
항목 24: 타입 변환이 모든 매개변수에 대해 적용되어야 한다면 비멤버 함수를 선언하자
- 어떤 함수에 들어가는 모든 매개변수(this 포인터가 가리키는 객체도 포함해서)에 대해 타입 변환을 해 줄 필요가 있다면, 그 함수는 비멤버이어야 한다.
항목 25: 예외를 던지지 않는 swap에 대한 지원도 생각해 보자
- std::swap이 사용자 정의 타입에 대해 느리게 동작할 여지가 있다면 swap 멤버 함수를 제공하자. 이 멤버 swap은 예외를 던지지 않아야 한다.
- 비멤버 버전 swap은 표준 swap의 경우 복사가 있기 때문에 이런 제약을 받지 않는다.
- 멤버 swap을 제공했으면, 이 멤버를 호출하는 비멤버 swap도 제공해야 한다. 클래스(템플릿 아님)에 대해서는 std::swap도 특수화해 줘야 한다.
- 사용자 입장에서 swap을 호출할 때는 std::swap에 대한 using 선언을 넣어 준 후에 네임스페이스 한정 없이 swap을 호출한다.
- 사용자 정의 타입에 대한 std 템플릿을 완전 특수화하는 것은 가능하다. 그러나 std에 어떤 것이라도 새로 추가하려고 들지는 말자.
'책 > 이펙티브 C++' 카테고리의 다른 글
이펙티브 C++(191p ~ 200p) (0) | 2024.01.25 |
---|---|
이펙티브 C++(183p ~ 190p) (1) | 2024.01.23 |
이펙티브 C++(160p ~169p) (1) | 2024.01.21 |
이펙티브 C++(149p ~ 160p) (0) | 2024.01.19 |
이펙티브 C++(142p ~ 149p) (0) | 2024.01.18 |