KoreanFoodie's Study
C++ 기초 개념 16-3 : decltype 과 std::declval 본문
모두의 코드를 참고하여 핵심 내용을 간추리고 있습니다. 자세한 내용은 모두의 코드의 씹어먹는 C++ 강좌를 참고해 주세요!
decltype
decltype 키워드는 C++ 11 에 추가된 키워드로, decltype 라는 이름의 함수처럼 사용된다.
decltype(/* 타입을 알고자 하는 식 */)
이때, decltype 은 함수와는 달리, 타입을 알고자 하는 식의 타입으로 치환되게 된다. 예를 들어,
#include <iostream>
struct A {
double d;
};
int main() {
int a = 3;
decltype(a) b = 2; // int
int& r_a = a;
decltype(r_a) r_b = b; // int&
int&& x = 3;
decltype(x) y = 2; // int&&
A* aa;
decltype(aa->d) dd = 0.1; // double
}
위 코드에서 decltype 은 각각 int, int&, int&& 로 치환되어 컴파일된다. 위와 같이 decltype 에 전달된 식이 괄호로 둘러쌓이지 않은 식별자 표현식(id-expression) 이라면 해당 식의 타입을 얻을 수 있다.
참고로 식별자 표현식이란 변수의 이름, 함수의 이름, enum 이름, 클래스 멤버 변수(a.b 나 a->b 같은 꼴) 등을 의미한다. 엄밀한 정의는 여기에서 볼 수 있는데, 쉽게 생각하면 어떠한 연산을 하지 않고 단순히 객체 하나만을 가리키는 식이라고 보면 된다.
그렇다면 만약 decltype 에 식별자 표현식이 아닌 식을 전달하면 어떨까? 그렇다면 해당 식의 값의 종류(value category) 에 따라 달라진다.
- 만일 식의 값 종류가 xvalue 라면 decltype 은 T&& 가 된다.
- 만일 식의 값 종류가 lvalue 라면 decltype 은 T& 가 된다.
- 만일 식의 값 종류가 prvalue 라면 decltype 은 T 가 된다.
이제 각 xvalue, lvalue, prvalue 의 개념에 대해 알아보도록 하자.
Value Category
모든 C++ 식(expression) 에는 두 가지 정보가 항상 따라다닌다. 바로 식의 타입과 값 카테고리(value category)이다.
값 카테고리는 좌측값/우측값 따위를 의미하는데, 사실 C++ 에서는 총 5개의 값 카테고리가 존재한다.
C++ 에서 어떠한 식의 값 카테고리를 따질 때 크게 두 가지 질문을 던질 수 있다.
- 정체를 알 수 있는가? : 정체를 알 수 있다는 말은 해당 식이 어떤 다른 식과 같은 것인지 아닌지를 구분할 수 있다는 말이다. 일반적인 변수라면 주소값을 취해서 구분할 수 있겠고, 함수의 경우라면 그냥 이름만 확인해보면 된다.
- 이동 시킬 수 있는가? : 해당 식을 다른 곳으로 안전하게 이동할 수 있는지의 여부를 묻는다. 즉, 해당 식을 받는 이동 생성자, 이동 대입 연산자 등을 사용할 수 있어야만 한다.
이를 바탕으로 값 카테고리를 구분하면 아래 표를 그릴 수 있다.
이동 시킬 수 있다 | 이동 시킬 수 없다 | |
정체를 알 수 있다 | xvalue | lvalue |
정체를 알 수 없다 | prvalue | 쓸모 없음! |
추가적으로, 정체를 알 수 있는 모든 식들을 glvalue 라고 하며, 이동시킬 수 있는 모든 식들을 rvalue 라고 한다. 그리고 C++ 에서 실체도 없으면서 이동도 시킬 수 없는 애들은 어차피 언어 상 아무런 의미를 갖지 않기 때문에 따로 부르는 명칭은 없다.
각 값 카테고리를 번역하면, lvalue 는 좌측값, prvalue (pure rvalue) 는 순수 우측값, xvalue (eXpiring value) 는 소멸하는 값, glvalue (generalized lvalue) 는 일반화된 좌측값, rvalue 는 우측값 이라 부를 수 있다.
위 그림으로 카테고리를 이해하자!
lvalue
평범한 int 타입 변수 i 를 보자.
int i;
i;
int&& x = i; // 컴파일 에러!
i 는 다른 변수(예를 들면 j)와 구별 가능하고, i 라는 식의 주소값은 실제 변수 i 의 주소값이 된다. 하지만 i 는 이동 불가능하므로, i 는 lvalue 이다!
이름을 가진 대부분의 객체들은 모두 lvalue 이다. 왜냐하면 해당 객체의 주소값을 취할 수 있기 때문이다. lvalue 카테고리 안에 들어가는 식들을 나열하면 : (자세한 내용은 이 링크에)
- 변수, 함수의 이름, 어떤 타입의 데이터 멤버 (예컨대 std::endl, std::cin) 등등
- 좌측값 레퍼런스를 리턴하는 함수의 호출식. std::cout << 1 이나 ++it 같은 것들
- a = b, a += b, a *= b 같은 복합 대입 연산자 식들
- ++a, --a 같은 전위 증감 연산자 식들
- a.m, p->m 과 같이 멤버를 참조할 때. 이 때 m 은 enum 값이거나 static 이 아닌 멤버 함수인 경우 제외. (아래 설명 참조)
class A {
int f(); // static 이 아닌 멤버 함수
static int g(); // static 인 멤버 함수
}
A a;
a.g; // <-- lvalue
a.f; // <-- lvalue 아님 (아래 나올 prvalue)
- a[n] 과 같은 배열 참조 식들
- 문자열 리터럴 "hi"
등등이 이싿. 특히 이 lvalue 들은 주소값 연산자 (&) 를 통해 해당 식의 주소값을 알아낼 수 있다. 예를 들어 &++i 나 &std::endl 은 모두 올바른 작업이다. 또한 lvalue 들은 좌측값 레퍼런스를 초기화하는데에 사용할 수 있다.
그렇다면 아래 코드에서 a 는 어떤 값 카테고리를 가질까?
void f(int&& a)
{
a; // <-- ?
}
f(3);
a 는 우측값 레퍼런스이지만, 식 a 의 경우는 lvalue 이다. 왜냐하면 이름이 있기 때문이다(a 라는 이름)! a 의 타입은 우측값 레퍼런스가 맞지만, 식 a 의 값 카테고리는 lvalue 가 된다. 따라서 아래 식도 잘 컴파일 된다!
#include <iostream>
void f(int&& a) { std::cout << &a << std::endl;}
int main() { f(3); }
prvalue
아래 코드의 f( ) 를 보자.
int f() { return 10; }
f(); // <-- ?
f( ) 는 실체가 없다. 이는 주소값을 취할 수 없다는 뜻이다. 하지만 f( ) 는 우측값 레퍼런스에 붙을 수 있다. 따라서 f( ) 는 prvalue 이다.
prvalue 로 대표적인 것들은 아래와 같다.
- 문자열 리터럴을 제외한 모든 리터럴들. 42, true, nullptr 같은 애들
- 레퍼런스가 아닌 것을 리턴하는 함수의 호출식. 예를 들어서 str.substr(1, 2), str1 + str2
- 후위 증감 연산자 식. a++, a--
- 산술 연산자, 논리 연산자 식들. a + b, a && b , a < b 같은 것들을 의미한다. 물론, 이들은 연산자 오버로딩 된 경우들 말고 디폴트로 제공되는 것들을 말한다.
- 주소값 연산자 식 &a
- a.m, p->m 과 같이 멤버를 참조할 때. 이 때 m 은 enum 값이거나 static 이 아닌 멤버 함수이어야 함.
- this
- enum 값
- 람다식 [ ]( ) { return 0; }; 와 같은 애들.
등등이 있다.
이 prvalue 들은 정체를 알 수 없는 녀석들이기 때문에 주소값을 취할 수 없다. 따라서 &a++ 이나 &42 와 같은 문장은 모두 오류이다. 또한, prvalue 들은 식의 좌측에 올 수 없다. 하지만 prvalue는 우측값 레퍼런스와 상수 좌측값 레퍼런스를 초기화하는데 사용할 수 있다.
const int& r = 42;
int&& rr = 42;
int& rrr = 42; // <-- 불가능
xvalue
만약 값 카테고리가 lvalue 와 prvalue 로만 구분된다면, 좌측값으로 분류되는 식을 이동시킬 방법이 없는 문제가 생긴다. 이러한 형태의 값의 카테고리에 들어가는 식들로 가장 크게 우측값 레퍼런스를 리턴하는 함수의 호출식을 들 수 있다. 대표적으로 std::move(x) 가 있다. std::move 함수는 아래와 같이 생겼다.
template <class T>
constexpr typename std::remove_reference<T>::type&& move(T&& t) noexcept;
move 의 리턴 타입은 우측값 레퍼런스이다. 따라서 move 를 호출한 식은 lvalue 처럼 좌측값 레퍼런스를 초기화하는데 사용할 수도 있고, prvalue 처럼 우측값 레퍼런스에 붙이거나 이동 생성자에 전달해서 이동시킬 수 있다.
앞서 decltype 에 대해 설명했던 것을 다시 상기해보자.
- 만일 식의 값 종류가 xvalue 라면 decltype 은 T&& 가 된다.
- 만일 식의 값 종류가 lvalue 라면 decltype 은 T& 가 된다.
- 만일 식의 값 종류가 prvalue 라면 decltype 은 T 가 된다.
아래 코드를 보자.
int a, b;
decltype(a + b) c; // c 의 타입은?
a + b 는 prvalue 이므로, c 의 타입은 그냥 int 가 된다.
그렇다면 아래 식은 어떨까?
int a;
decltype((a)) b; // b 의 타입은?
(a) 는 lvalue 이므로, b 는 int& 로 추론된다! ( &(a) 처럼 주소를 취할 수 있음) 만약 괄호가 없으면, b 는 그냥 int 가 된다!
decltype 의 쓰임새
그렇다면 decltype 은 어디에 쓰일까? 사실 auto 도 비슷한 용도로 쓰일 수 있지 않을까? 아래의 코드를 비교해 보자.
const int i = 4;
auto j = i; // int j = i;
decltype(i) k = i; // const int k = i;
int arr[10];
auto arr2 = arr; // int* arr2 = arr;
decltype(arr) arr3 = arr; // int arr3[10];
위처럼, auto 는 가능한 타입을 알아서 추론하는 반면, decltype 은 원하는 타입 그대로를 받는다.
또한 템플릿 함수를 사용하는 경우에도 decltype 을 사용할 수 있다.
template <typename T, typename U>
void add(T t, U u, decltype(t + u)* result)
{
*result = t + u;
}
이걸 다음과 같이 간략화하면 어떨까?
template <typename T, typename U>
decltype(t + u) add(T t, U u)
{
return t + u;
}
위 코드는 t 와 u 를 보고 이건 뭐지 라는 컴파일 에러를 뱉는다. t 와 u 의 정의가 decltype 보다 나중에 나오기 때문인데, 이 경우 함수의 리턴값을 인자들 정의 부분 뒤에 써야 한다. C++ 14 부터 추가된 아래와 같은 문법으로 구현 가능하다.
template <typename T, typename U>
auto add(T t, U u) -> decltype(t + u)
{
return t + u;
}
람다 함수와 흡사하게 생겼다. 리턴값은 그냥 auto 로 써놓고, -> 뒤에 실제의 함수의 리턴 타입을 정의해주는 방식이다.
std::declval
이제 std::declval 함수를 살펴보자. (키워드가 아니라, <utility> 에 정의된 함수임)
다음 코드를 보자.
struct A {
int f() { return 0; }
};
struct B {
B(int x) {}
int f() { return 0; }
};
int main() {
decltype(A().f()) ret_val; // int ret_val; 이 된다.
decltype(B().f()) ret_val; // B() 는 문법상 틀린 문장 :(
}
클래스 A 는 기본 생성자 ( A( ) ) 가 있으므로 컴파일이 되지만, B 의 경우 B( ) 를 사용할 수가 없다. decltype 안에 들어가는 식은, 컴파일 시에 decltype( ) 전체 식이 타입으로 변환되므로 decltype 안에 있는 식은 런타임 시에 실행되는 것이 아니다.
다음과 같은 코드를 보자.
template <typename T>
decltype(T().f()) call_f_and_return(T& t) {
return t.f();
}
struct A {
int f() { return 0; }
};
struct B {
B(int x) {}
int f() { return 0; }
};
int main() {
A a;
B b(1);
call_f_and_return(a); // ok
call_f_and_return(b); // BAD
}
템플릿을 이용해 decltype 을 사용했지만, 이전의 예시처럼, 생성자 이슈가 있어 컴파일 오류가 발생할 것이다.
이는 std::declval 을 사용하면 깔끔하게 해결할 수 있다.
#include <utility>
template <typename T>
decltype(std::declval<T>().f()) call_f_and_return(T& t) {
return t.f();
}
즉, T( ) 대신 std::declval<T>( ) 를 써준 것인데, std::declval 에 타입 T 를 전달하면, T 의 생성자를 직접 호출하지 않더라도 T 가 생성된 객체를 나타낼 수 있다.
즉, B 의 경우 기본 생성자가 없음에도 마치 T( ) 를 한 것 같은 효과를 낸 것이다.
참고로, declval 함수는 타입 연산에서만 사용해야지, 런타임에 사용하면 오류가 발생한다. 다음 코드를 보자.
#include <utility>
struct B {
B(int x) {}
int f() { return 0; }
};
int main() { B b = std::declval<B>(); } // Error!
참고로 C++ 14 부터는 auto 를 붙이면 함수의 리턴 타입을 컴파일러가 알아서 유추해준다.
물론 그렇다고 해서 declval 는 쓰임새가 있다. 다음 글에서 <type_traits> 라이브러리를 다루며 decltype 과 std::declval 을 사용한 TMP 기법들을 배워보자.
'Tutorials > C++ : Beginner' 카테고리의 다른 글
C++ 기초 개념 17-2 : C++ 정규 표현식 <regex> 사용하기 (0) | 2022.05.26 |
---|---|
C++ 기초 개념 17-1 : type_traits 라이브러리, SFINAE, enable_if (0) | 2022.05.25 |
C++ 기초 개념 16-2 : constexpr 와 컴파일 타임 상수 (0) | 2022.05.23 |
C++ 기초 개념 16-1 : C++ 유니폼 초기화(Uniform Initialization) (0) | 2022.05.23 |
C++ 기초 개념 15-5 : 쓰레드풀(ThreadPool) 만들기 (0) | 2022.04.21 |