관리 메뉴

KoreanFoodie's Study

Modern Effective C++ 정리 1 : 형식 연역 본문

Tutorials/C++ : Advanced

Modern Effective C++ 정리 1 : 형식 연역

머니덕 2022. 9. 20. 10:24

C++ 프로그래머의 필독서이자 바이블인, 스콧 마이어스의 Modern Effective C++ 를 읽고 기억할 내용을 요약하고 있습니다. 꼭 읽어보시길 추천드립니다!


항목 1 : 템플릿 형식 연역 규칙을 숙지하라

핵심 :

1. 템플릿 형식 연역 도중 참조 형식의 인수들은 비참조로 취급된다. 즉, 참조성이 무시된다.
2. 보편 참조 매개변수에 대한 형식 연역 과정에서 왼값 인수들은 특별하게 취급된다.
3. 값 전달 방식의 매개변수에 대한 형식 연역 과정에서 const 또는 volatile(또는 그 둘 다인) 인수는 비 const, 비 volatile 인수로 취급된다.
4. 템플릿 형식 연역 과정에서 배열이나 함수 이름에 해당하는 인수는 포인터로 붕괴한다. 단, 그런 인수가 참조를 초기화하는데 쓰이는 경우, 포인터로 붕괴하지 않는다.

 

C++ auto 는 템플릿에 대한 형식 연역을 기반으로 작동한다. 예시를 보자.

// 템플릿 선언의 형태
template<typename T>
void f(ParamType param) {}
...
// expr 로부터 T 와 ParamType 을 연역
f(expr);

// 예시코드
template<typename T>
void f(const T& param) {}

int main()
{
	int x = 0;
	// const int& 가 아닌 int 로 f 호출
	f(x);
}

이는, T 에 대해 연역된 형식은 expr 의 형식뿐만 아니라 ParamType 형태에도 의존한다. 형식 연역 시나리오를 3 가지로 나누어 살펴보자.

  • ParamType 이 포인터 또는 참조 형식이지만 보편 참조(universal reference) 는 아닌 경우
  • ParamType 이 보편 참조인 경우
  • ParamType 이 포인터도 아니고 참조도 아닌 경우

 

경우 1 : ParamType 이 포인터 또는 참조 형식이지만 보편 참조(universal reference) 는 아님

이 경우, 형식 연역은 다음과 같이 진행된다.

  1. 만일 expr 이 참조 형식이면 참조 부분을 무시
  2. 그 다음 expr 의 형식을 ParamType 에 대해 패턴 부합(pattern-matching) 방식으로 대응시켜 T 의 형식을 결정
template<typename T>
void f(T& param) {}

template<typename T>
void f2(const T& param) {}

int main()
{
	int x = 27;
	const int cx = x;
	const int& rx = x;

	// T 는 int, param 의 형식은 int&
	f(x);
	// T 는 const int, param 의 형식은 const int&
	f(cx);
	// T 는 const int, param 의 형식은 const int&
	f(rx);

	// T 는 int, param 의 형식은 const int&
	f2(x);
	// T 는 int, param 의 형식은 const int&
	f2(cx);
	// T 는 int, param 의 형식은 const int&
	f2(rx);
}

위에서 T& 대신 T* 를 사용해도 타입은 같은 원리로 연역된다.

 

경우 2 : ParamType 이 보편 참조임

만일 expr 이 왼값이면, T 와 ParamType 둘 다 왼값 참조로 연역된다(매우 어색할 수 있다). 만약 expr 이 오른값이면, '정상적인'(경우 1의) 규칙들이 적용된다.

template<typename T>
void f(T&& param);

int main()
{
	int x = 27;
	const int cx = x;
	const int& rx = x;

	// x 는 왼값. 따라서 T 는 int&,
	// param 의 형식 역시 int&
	f(x);
	// cx 는 왼값. 따라서 T 는 const int&,
	// param 의 형식 역시 const int&
	f(cx);
	// rx 는 왼값. 따라서 T 는 const int&,
	// param 의 형식 역시 const int&
	f(rx);
	// x 는 오른값. 따라서 T 는 int,
	// param 의 형식은 int&&
	f(27);
}

이 예들이 각각 해당 형식으로 연역되는 이유는 항목 24에서 후술하도록 한다.

 

경우 3 : ParamType 이 포인터도 아니고 참조도 아님

// param 이 값으로 전달
template<typename T>
void f(T param);

위의 경우, expr 형식의 참조성과 const 는 둘 다 무시된다. 심지어 volatile 도 무시된다!

template<typename T>
void f(T param);

int main()
{
	int x = 27;
	const int cx = x;
	const int& rx = x;

	// T 와 param 의 형식은 둘 다 int
	f(x);
	// T 와 param 의 형식은 둘 다 int
	f(cx);
	// T 와 param 의 형식은 둘 다 int
	f(rx);
}

 

그럼 다음과 같은 경우는 어떨까?

template<typename T>
void f(T param);

...

const char* const ptr = "Fun";
f(ptr);

위 경우, 포인터 자체(ptr) 가 값으로 전달되어, param 은 const char* 의 형식을 가지게 된다. 즉, ptr 가 가리키는 것의 const 성은 보존되나, ptr 자체의 const 성은 사라지는 것이다.

 

배열 인수

초보 프로그래머는 흔히 배열이 배열의 첫 원소를 가리키는 포인터로 붕괴(decay) 하는 현상은, 템플릿에도 마찬가지로 적용된다.

template<typename T>
void f(T param) {}

int main()
{
	const char name[] = "Tony";
	const char* pName = name;

	// T 는 const char[4] 가 아닌 const char* 로 연역됨
	f(name);
}

아래와 같이 T 에 참조자를 달아 주면, f(name) 을 사용할 때 T 를 const char[4] 로 연역할 수 있다.

template<typename T>
void f(T& param) {}

이를 응용하면, 컴파일 타임 도중에 배열에 담긴 원소들의 갯수를 알아낼 수 있다! 물론 std::array 를 사용하는 것이 더 편하다.

template<typename T, std::size_t N>
constexpr std::size_t arraySize(T (&)[N]) noexcept
{
	return N;
}

...

int keyVals[] = {1, 2, 3, 4};

// 템플릿을 이용한 방식
int mappedVals[arraySize(keyVals)];

// 좀 더 모던한 방식
std::array<int, arraySize(keyVals)> mappedVals;

 

함수 인수

배열처럼, 함수 형식도 함수 포인터로 붕괴할 수 있다. 예시를 보자.

void someFunc(int, double);

template<typename T>
void f1(T param);

template<typename T>
void f2(T& param);

// param 은 void (*)(int, double)
f1(someFunc);
// param 은 void (&)(int, double)
f2(someFunc);

 

 

항목 2 : auto 의 형식 연역 규칙을 숙지하라

핵심 :

1. auto 형식 연역은 대체로 템플릿 형식 연역과 같지만, auto 형식 연역은 중괄호 초기치가 std::initializer_list 를 나타낸다고 가정하는 반면, 템플릿 형식 연역은 그렇지 않다는 차이가 있다.
2. 함수의 반환 형식이나 람다 매개변수에 쓰인 auto 에 대해서는 auto 형식 연역이 아니라 템플릿 형식 연역이 적용된다.

 

한 가지 기이한 예외를 빼면, auto 형식 연역이 곧 템플릿 형식 연역이다. 그 기이한 한 가지 다른 예외를 보자.

#include <iostream>
#include <typeinfo>

int main()
{
	// x1, x2, x3 는 int
	int x1 = {27};
	int x2{27};
	auto x3{27};

	// x4 의 형식은 std::initializer_list<int>
	auto x4 = {27, 28, 29};
	// 에러! 값들이 다 같은 형식이어야 함
	auto x5 = {1, 2, 3.0};

	std::cout << typeid(x1).name() << std::endl; // i
	std::cout << typeid(x4).name() << std::endl; // St16initializer_listIiE
}

즉, auto 의 경우 중괄호를 이용했을 때 std::initializer_list 가 불린다는 것이 단 한가지의 차이점이다.

 

중괄호 초기치를 이용한 템플릿 연역 예시를 보자.

template<typename T>
void f(T param);
// 오류! 에 대한 형식 연역 불가

f({1, 2, 3}); 

template<typename T>
void f(std::initializer_list<T> param);

// T 는 int 로, param 은 std::initializer_list<int> 로 연역
f({1, 2, 3});

 

함수 반환 형식을 auto 로 지정하면 템플릿 형식 연역의 규칙들이 적용된다. 이는 람다 함수의 경우도 마찬가지이다.

auto createInitList()
{
	// 오류! {1, 2, 3} 의 형식 연역 불가
	return {1, 2, 3};
}


int main()
{
	 std::vector<int> v;

	 auto resetV = [&v](const auto& new Value) { v = newValue; };

	 // 오류! {1, 2, 3} 의 형식 연역 불가
	 resetV({1, 2, 3});
}

 

 

항목 3 : decltype 의 작동 방식을 숙지하라

핵심 :

1. decltype 은 항상 변수나 표현식의 형식을 아무 수정 없이 보고한다.
2. decltype 은 형식이 T 이고 이름이 아닌 왼값 표현식에 대해서는 항상 T& 형식을 보고한다.
3. C++14 는 decltype(auto) 를 지원한다. decltype(auto)는 auto 처럼 초기치로부터 형식을 연역하지만, 그 형식 연역 과정에서 decltype 의 규칙들을 적용한다.

 

컨테이너의 operator[ ] 반환 형식을 손쉽게 표현해 보자.

template<typename Container, typename Index>
auto authAndAccess(Container& c, Index i)
->  decltype(c[i])
{
	authenticateUser();
	return c[i];
}

이름 앞에 auto 를 지정하는 것은 형식 연역과는 관련이 없다. 여기서의 auto 는 C++11 의 후행 반환 형식(trailing return type) 구문이 쓰인다는 점을 나타내는 것이다(반환 형식을 매개변수 목록 다음에서 정함).

C++14 부터는 이렇게 간단하게 만들 수도 있다.

template<typename Container, typename Index>
auto authAndAccess(Container& c, Index i)
{
	authenticateUser();
	return c[i];
}

이 경우에는 반환 형식이 c[i] 로부터 연역된다. 그런데 이 경우, 다음과 같은 코드는 컴파일되지 않는다.

template<typename Container, typename Index>
auto authAndAccess(Container& c, Index i)
{
	return c[i];
}


int main()
{
	std::deque<int> d{1,2,3,4,5,6};
	authAndAccess(d, 5) = 10;
}

왜냐하면, 형식을 연역하는 과정에서 Container 타입이 참조자가 제거되어 반환 형식은 int 가 되기 때문이다. 즉, 함수의 반환값으로서의 이 int 는 오른값이며, 결과적으로 위의 코드는 오른값 int 에 10을 배정하려 해서 컴파일이 되지 않는 것이다.

 

위 코드는 다음과 같이 고치면 된다. 함수의 반환 형식에 decltype 형식 연역이 적용되게 만드는 것으로, autoAndAccess 가 c[i] 의 반환 형식과 정확히 동일한 형식을 반환하게 만드는 것이다!

template<typename Container, typename Index>
decltype(auto) authAndAccess(Container& c, Index i)
{
	return c[i];
}

decltype(auto) 는 다음과 같은 일반적인 상황에서도 유용하다.

class Widget {};

int main()
{
	Widget w;
	const Widget& cw = w;

	// auto 형식 연역 : Widget
	auto myWidget1 = cw;

	// decltype 형식 연역 : const Widget&
	decltype(auto) myWidget2 = cw;
}

 

만약 authAndAccess 가 왼값 참조 뿐만이 아니라 오른값 참조도 받을 수 있게 만드려면 어떻게 해야 할까? 간단한 해결책은, 다음과 같이 보편 참조를 활용하는 것이다.

template<typename Container, typename Index>
decltype(auto) authAndAccess(Container&& c, Index i)
{
	return c[i];
}

// 팩터리 함수
std::deque<std::string> makeStringDeque();

...

// 팩터리 함수가 만든 오른값 deque 의 다섯 번째 원소의 복사본을 만든다
auto s = autoAndAccess(makeStringDeque(), 5);

마지막으로, 항목 25 의 조언에 따라 보편 탐조에 std::forward 를 적용하면 끝이다.

// C++14 이상
template<typename Container, typename Index>
decltype(auto) authAndAccess(Container&& c, Index i)
{
	return std::forward<Container>(c)[i];
}

// C++11 버전
template<typename Container, typename Index>
auto authAndAccess(Container&& c, Index i)
 -> decltype(std::forward<Container>(c)[i])
{
	return std::forward<Container>(c)[i];
}

 

마지막으로, decltype 이 예상 밖의 결과를 제공하는 한 가지 경우를 보자.

decltype(auto) f1()
{
	int x = 0;
	// decltype(x) 는 int 이므로 int 를 반환
	return x;
}

// C++ 는 (x) 를 왼값으로 정의한다!
decltype(auto) f2()
{
	int x = 0;
	// decltype((x)) 는 int& 이므로 int& 를 반환
	return (x);
}

따라서, decltype(auto) 를 사용할 때는 주의해야 한다.

 

 

항목 4 : 연역된 형식을 파악하는 방법을 알아두라

핵심 :

1. 컴파일러가 연역하는 형식을 IDE 편집기나 컴파일러 오류 메시지, Boost TypeIndex 라이브러리를 이용해서 파악할 수 있는 경우가 많다.
2. 일부 도구의 결과는 유용하지도 않고 부정확할 수 있으므로, C++ 의 형식 연역 규칙들을 제대로 이해하는 것은 여전히 필요한 일이다.

 

형식 연역 정보를 얻는 방법은 세 가지 시점으로 분류할 수 있다.

1. IDE 편집기

 

2. 컴파일러의 진단 메시지

decltype 을 사용하면 다음과 같은 경우, 타입을 컴파일 타임에 알아낼 수 있다.

template<typename T>
class TD;

int main()
{
	auto x  = 1.0f;
	TD<decltype(x)> xType;

	auto y = (x);
	TD<decltype(y)> yType;
}

다음과 같은 에러 메시지를 출력한다!

aggregate 'TD<float> xType' has incomplete type and cannot be defined (yType 도 동일함)

 

3. 실행시점 출력

typeid 와 std::typeinfo::name 등을 사용해서 printf 를 하면, 런타임에 해당 변수들의 타입을 알아낼 수 있다.

auto x  = 1.0f;
const int* y = new int(10);

std::cout << typeid(x).name() << std::endl; // f
std::cout << typeid(y).name() << std::endl; // PKi

물론 결과값은 컴파일러마다 달라서, 해석하기 어려운 경우도 있다. 그리고 typeid 가 제대로 동작하지 않는 경우도 있는데, 다음 예시를 보자.

class Widget {};

template<typename T>
void f(const T& param)
{
	using namespace std;
	// T 를 표시 : T = PK6Widget
	cout << "T = " << typeid(T).name() << endl;
	// param 을 표시 : param = PK6Widget
	cout << "param = " << typeid(param).name() << endl;
}

// 팩터리 함수
std::vector<Widget> createVec() { return std::vector<Widget>(10); }


int main()
{
	const auto vw = createVec();

	if (!vw.empty())
	{
		f(&vw[0]);
	}
}

PK 는 "const 를 가리키는 포인터" 를 뜻한다. 숫자 6은 그냥 클래스 이름(Widget) 의 글자 수이다. 즉, T 와 param 이 둘다 const Widget* 형식임을 출력하고 있다. 즉, const Widget* 라는 뜻이다.

그런데, T 와 param 의 형식이 같은 것은 이상하다. T 는 const Widget* 가 맞지만, param 은 const Widget* const & 형식을 가져야 하기 때문이다. 사실 이는 표준을 준수한 것으로, std::type_info::name 은 주어진 형식을 마치 템플릿 함수에 값 전달매개변수로서 전달된 것처럼 취급해야 한다. 항목 1에서 설명했듯, 값 전달의 경우 참조와 const 성이 제거되어 param 의 형식이 const Widget* 이 된 것이다. 

 

대신 Boost.TypeIndex 을 이용하면 정확한 타입을 얻을 수 있다.

#include <boost/type_index.hpp>

class Widget {};

template<typename T>
void f(const T& param)
{
	using namespace std;
	using boost::typeindex::type_id_with_cvr;

	// T 를 표시 : T = Widget const*
	cout << "T = " << type_id_with_cvr<T>().pretty_name() << endl;
	// param 을 표시 : param = Widget const* const&
	cout << "param = " << type_id_with_cvr<decltype(param)>().pretty_name() << endl;
}

boost::typeindex::type_id_with_cvr 은 전달된 형식 인수의 const 나 volatile, 참조 한정사들을 그대로 보존한다!

 

 

0 Comments
댓글쓰기 폼