Item 1,2 - 타입 추론

마지막 수정 시각: 2020-10-26 22:03:12

parameter와 argument의 차이

parameter(매개 변수)는 함수의 선언에 쓰이는 형식인수를 지칭하는 말이고, argument(인자)는 함수를 호출할 때 실질적으로 들어가는 실인수를 지칭하는 말이다.

예시 코드

void f(int a, int b); //a와 b는 parameter이다.

int main()
{
    int k = 5;

    f(k,3); //여기서 실제 함수 호출에 들어간 k와 3은 argument이다.
}

lvalue, rvalue

C++에서 lvalue와 rvalue의 의미는 다음과 같다.

int a,b;
std::string x;
double c = a;

위의 a,b,c,x와 같이 일반적으로 메모리 공간을 가지고 주소를 참조할 수 있는 변수들은 모두 lvalue이다. 반대로 1,2,'a' 등과 같이 주소를 참조할 수 없는 리터럴(literal) 상수들은 당연히 rvalue이다.

++i;
i++;

위의 예에서 ++i는 lvalue지만 i++은 rvalue이다. ++i는 값을 증가한 후 값을 증가시킨 객체 자체를 리턴하지만, i++은 값을 증가시키기 전 상태를 임시로 저장한 객체를 리턴하기 때문이다.

lvalue, rvalue 모두 const 형식 지정자를 가질 수 있다.

int a();
const int b();

int c = 5; //lvalue.
const int c = 3; // const lvalue. 값을 수정할 수 없지만 lvalue다.

a(); //rvalue.
b(); //const rvalue. 

reference

reference(참조)는 개체의 또다른 이름이라고 볼 수 있다. 내부적으로 해당 개체의 주소를 보유하지만 동작할 때는 해당 개체 그 자체처럼 동작한다. reference에는 lvalue reference와 rvalue reference가 있다.

lvalue reference

lvalue reference의 기본적인 사용 형태는 다음과 같다.

(type name)& (identifier) = (lvalue expression);

즉 lvalue reference는 lvalue만 참조할 수 있으며 rvalue는 참조할 수 없다. 단, const lvalue reference는 rvalue도 참조할 수 있다. 예시 코드를 통해 살펴보자.

int a = 3;
int& b = a; // a는 lvalue이므로 적법한 문장
int& c = 3; // 3은 rvalue이므로 compile error가 발생한다.
const int& d = 3; //d는 const lvalue reference이므로 rvalue도 참조할 수 있다.

lvalue reference는 어떤 lvalue 개체에 대한 별명이라고 볼 수 있으므로, lvalue reference 변수 역시 마찬가지로 lvalue이다.

int& a();
const int& b();
int* c();
const int* d();

a(); //lvalue
b(); //const lvalue
c(); //rvalue.
*(c()); //lvalue. 주소값 자체는 rvalue지만, 그에 대한 역참조 연산(*)을 한 결과는 lvalue이다.
*(d()); //const lvalue.

rvalue reference

C++ 11부터 추가된 문법이다. rvalue reference의 기본적인 형태는 다음과 같다.

(type name)&& (identifier) = (rvalue expression);

rvalue reference를 이용하면 rvalue의 값을 참조하여 해당 값의 생명 주기를 늘일 수 있다. 즉 식이 끝나면 사라지는 값인 rvalue를 참조해서 해당 값을 그 식이 끝난 다음에도 유지하여 참조할 수 있게 만들어준다. 예시 코드를 살펴보자.

int sum(int a, int b)
{
    return a + b;
}

sum(3,4); //이 함수의 리턴값 자체는 rvalue이므로 이 식이 끝나는 순간 사라진다.
int&& val = sum(3,4);

//식이 끝난 다음에도 해당 값을 유지하여 참조할 수 있게 된다. 
std::cout << val << std::endl;

이런식으로 rvalue reference에 의해 유지되는 값을 xvalue ( "eXpiring" value - 다 죽어가는 값 )라고 부른다. rvalue reference에 의해 유지되어 이름이 있는 이런 변수는 lvalue(glvalue)로 취급된다. 즉, 주소를 취할 수 있다.

int&& val = sum(3,4);
int* pVal = &val;

std::cout << *pVal << std::endl; //7 출력.

당장 이런 예제만 봐서는 rvalue reference의 필요성을 느끼기 힘들다. rvalue reference의 필요성은 move semantics에서 뚜렷하게 드러난다.

value 체계 정리

C++ 11 표준(ISO/IEC 14882:2011)에서 분류한 표현식(expression)의 종류는 다음과 같다.

move semantics

이동(move)은 복사와는 달리 해당 객체의 내용이 말 그대로 다른 객체로 그대로 이동되는 것을 말한다. 내용을 복사하는 게 아니라 이동하는 것이기 때문에 속도가 빠르며, 원본 객체의 정보는 모두 사라진다.

클래스 객체는 두 가지 방법으로 복사되거나(copied) 이동될(moved) 수 있다. 하나는 함수 호출 시의 인자 전달 및 함수의 리턴값을 포함한 객체 초기화이고, 두번째는 대입 연산이다. 개념적으로 이 두가지 연산은 복사/이동 생성자와 복사/이동 대입 연산자를 통해 구현된다.

T 타입 클래스의 이동 생성자와 이동 대입 연산자는 다음과 같은 형태로 선언된다.

//생성자
T(T&& other);
T(const T&& other); // 둘 다 상관없음. volatile도 붙어도 됨. C++ 표준은 T(T&&)형태가 낫다는 듯.

//대입 연산자
T& operator=(T&&);

예시 코드.

class X
{
    X(int);
    X(const X&, int = 1);
};

X a(1); // X(int) 호출
X b(a , 0); // X(const X&, int) 호출
X c = b; // X(const X&, int) 호출

class Y
{
    Y(const Y&);
    Y(Y&&);
};
extern Y f(int);

Y d(f(1)); // Y(Y&&) 호출
Y e = d; //Y(const Y&) 호출

std::move를 이용하면 lvalue를 rvalue로 변환할 수 있다.

class A
{
    A();
    A(const A&); //복사 생성자
    A(A&&); //이동 생성자
    A& operator =(const A&); // 대입 연산자
    A& operator =(A&&); // 이동 대입 연산자
};

A a; // a 생성
A b(a); // a를 이용해서 b 생성
A c(std::move(b)); //b를 이동해서 c 생성. b는 이제 의미 없음.
A d; // d 생성
d = std::move(c); //c를 d로 이동. 이제 c는 의미없음.

타입 추론(Deducing types)

C++에서 template, auto, decltype에서 타입 추론이 일어난다. 각각에서 어떤 식으로 타입 추론이 일어나는지 살펴보자.

template에서의 타입 추론

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

f(expr); // expr로부터 ParamType과 T를 추론해내서 동작한다.

위와 같은 형태의 함수 템플릿을 바탕으로 두 개의 타입 T와 ParamType이 어떻게 추론되는지 살펴볼 것이다. 이 때 크게 3가지 경우로 나누어 살펴볼 수 있다.

Case 1. ParamType이 포인터이거나 참조 타입이지만 universal reference는 아닌 경우

이 경우 타입 추론은 다음과 같이 동작한다.

예를 들어 template 함수가 아래와 같은 형태로 선언되어 있다고 하자.

template<typename T>
void f(T& param); //param이 참조형인 경우

int x = 27;
const int cx = x;
const int& crx = x;
int&& rrx = 27;
const int&& crrx = 27;

위와 같이 변수와 함수가 선언되어있다고 할 때 각 변수에 대한 함수 호출의 결과 추론되는 타입은 아래와 같다.

f(x); // T는 int, 매개 변수(parameter)의 타입은 int&로 추론된다.
f(cx); // T는 const int, 매개 변수의 타입은 const int&로 추론된다.
f(crx); // T는 const int, 매개 변수의 타입은 const int&로 추론된다.
f(rrx); // T는 int, 매개 변수의 타입은 int&로 추론된다.
f(crrx); // T는 const int, 매개 변수의 타입은 const int&로 추론된다.

위 예제에서 볼 수 있듯이 참조형의 경우 그게 lvalue reference든 rvalue reference든 상관없이 일관성있게 참조는 무시한 후 타입을 추론하는 것을 알 수 있다.

T타입에 상수성이 붙은 경우에도 별반 다를 바 없이 동작한다.

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

f(x); // T는 int, 매개 변수의 타입은 const int&이다.
f(cx); // T는 int, 매개 변수의 타입은 const int&이다.
f(rx); // T는 int, 매개 변수의 타입은 const int&이다.

함수의 인자 param이 참조형이 아니라 포인터 타입이라고 해도 이는 동일하게 동작한다.

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

const int* px = &x;

f(&x); //T는 int, 매개 변수의 타입은 int* 이다.
f(px); //T는 const int, 매개 변수의 타입은 const int* 이다.

Case 2. ParamType이 universal reference인 경우

universal reference 매개변수를 갖고 있는 템플릿 함수는 매개변수를 T&&형태로 선언한다. 형태는 rvalue reference와 동일하지만 lvalue가 인자로 넘어갈 때 rvalue reference랑은 다르게 동작한다.

정확한 타입 추론 과정은 다음과 같다.

예시를 통해 살펴보자.

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

int x = 27;
const int cx = x;
const int& crx = x;
int&& rrx = 27;
const int&& crrx = 27;

f(x); //x가 lvalue이므로 T는 int&가 된다. 매개 변수의 타입도 마찬가지로 int&.
f(cx); //cx가 lvalue이므로 T는 const int&가 된다. 매개 변수의 타입도 마찬가지.
f(crx); //crx가 lvalue이므로 T는 const int&가 된다. 매개 변수의 타입도 마찬가지.
f(rrx); //rrx는 rvalue reference지만, 참조 타입이며 
        //이름이 있는 rvalue reference는 lvalue 취급을 받는다.
        //따라서 위와 마찬가지로 T는 int&가 되며, 매개 변수의 타입도 마찬가지로 int&.
f(crrx); //crrx역시 마찬가지다. T는 const int&가 되며 매개 변수의 타입도 마찬가지다.
f(27); //27은 rvalue이다. 따라서 T는 int이며, 매개 변수의 타입은 int&&가 된다.

Case 3. ParamType이 참조도 포인터도 아닌 경우

이 경우는 값에 의한 전달이 일어나는 경우이다.

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

이 때에는 case1에서 처럼 expr의 타입이 참조라면 참조 부분은 무시한다.참조 부분을 무시한 후에는 const, volatile같은 형식 지정자도 모두 무시한다. 값에 의한 전달의 경우 param은 원래 expr과는 완전히 다른 개체이기 때문에 cx,rx 등의 타입이 param이 수정될 수 있는 지의 여부에 영향을 끼칠 수 없다. 따라서 const, volatile같은 형식 지정자는 모두 무시된다.

f(x); //T와 매개변수의 타입은 모두 int다.
f(cx); //역시 마찬가지로 둘다 int.
f(rx); //역시 마찬가지로 둘다 int.

포인터 타입의 경우 해당 포인터가 가리키는 개체에 대한 상수성은 보존된다. 단, 포인터 자체의 상수성은 보존되지 않는다.

const int* pa = a;
const int* const pa2 = a; 

f(pa); // T와 매개 변수의 타입은 모두 const int*이다.
f(pa2); //마찬가지로 T와 매개 변수의 타입은 const int*가 된다.

배열 타입과 포인터 타입의 차이

배열과 포인터 타입은 서로 호환될 뿐 결코 같은 타입이 아니다. 하지만 값에 의한 전달의 경우 타입 추론시 타입을 포인터로 추론한다.

const char name[] = "J. P. Briggs"; // const char[13] 타입
const char* ptrToName = name; // 포인터로 배열을 가리킬 수 있다.

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

f(name); //이 경우 T의 타입은 const char*로 추론된다.

하지만 템플릿에서 인자의 타입이 참조형인 경우 이야기는 달라진다. 배열을 참조할 수 있기 때문에 이 경우 T의 타입은 포인터로 추론되지 않는다.

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

f(name); // T의 타입은 const char[13].
         // 매개 변수의 타입은 const char(&)[13] (배열의 참조)으로 추론된다.

템플릿이 universal reference인 경우는 어떨까? 위 case 2에 언급된 것과 거의 같은 과정을 거친다. 배열 자체는 lvalue이므로 T의 타입과 매개 변수의 타입 모두 참조형으로 추론된다.

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

f(name); // T의 타입과 매개 변수의 타입 모두 const char(&)[13].

함수 타입 역시 포인터로 가리킬 수 있다. 함수 포인터로 함수를 가리키는 부분은 배열을 포인터로 가리키는 경우와 거의 동일하게 다룰 수 있다. 타입 추론 규칙도 비슷.

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

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

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

void func();

f1(func); // 이 경우 매개변수 타입과 T 타입 모두 void(*)()로 추론된다.
f2(func); // 이 경우 T는 void(), 매개 변수는 void(&)()로 추론된다.
f3(func); // 이 경우 둘 모두 void(&)()로 추론.

auto에서의 타입 추론

auto와 템플릿에서의 타입 추론은 한 가지 특이한 예외를 제회하면 사실상 동일하다.

auto와 템플릿의 타입 추론이 다른 부분은 uniform initialization과 관련된 부분이다.

C++11에서 어떤 변수를 초기화하는 방법은 4가지가 존재한다.

int x1 =27;
int x2(27);
int x3 = { 27 }; //uniform initialization
int x4{ 27 }; //uniform initialization

아래 uniform initialization과 관련하여 타입 추론에 대한 예외가 발생한다.

auto x1 = 27; // int 타입으로 추론된다.
auto x2(27); //마찬가지
auto x3 = { 27 }; // std::initializer_list<int> 타입으로 추론된다.
auto x4{ 27 }; // 마찬가지.

auto로 선언된 변수의 이니셜라이저가 중괄호로 감싼 형태면, 타입은 std::initializer_list로 추론된다. 중괄호 안의 타입의 값이 다른 경우처럼 적합한 타입을 추론할 수 없는 경우 에러를 내뱉는다.

하지만 템플릿의 경우 이와 같이 사용할 경우 타입 추론은 실패한다.

auto x = {11, 23, 9}; // 가능. std::initializer_list<int>로 추론.
auto& x2 = {11, 23, 9}; // 가능. std::initializer_list<int>&로 추론.
auto&& x3 = {11, 23, 9}; // 가능. std::initializer_list<int>&&로 추론.

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

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

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

//템플릿은 어느 경우에도 추론할 수 없다.
f({ 11, 23, 9 });
f2({ 11, 23, 9});
f3({ 11, 23, 9});

이 경우만이 템플릿과 auto의 유일한 차이. 중괄호 이니셜라이저에 대해 auto는 std::initializer_list를 추론해내지만 템플릿은 이를 추론해내지 못한다.