본문 바로가기

3.구현/C or C++

[c++] C++14 형식연역

들어가기

C++에서 형식연역에서 다룰려고 한다. 형식연역은 미정인 형식이 호출 시점에 형식이 입력되는 값에 의해 결정되는 작업을 말한다. 형식연역은 형식추론이라고 한다. 즉, 주변 상황에 따라 형식을 추론해서 결정한다는 의미이다. 너무 복잡할 수 있지만 생각보다 단순하다(?).

작성자: ospace114@empal.com, http://ospace.tistory.com/

템플릿 형식연역

일반적인 템플릿 함수 선언에 대한 형식을 살펴보자.

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

T로 표현되는 ParamType은 param 인자에 대한 형식이다. 그리고 함수 f()을 호출할 때 expr로 수식이 입력된다. 입력되는 값에 따라서 T의 형식을 결정하는 작업이 형식연역이다. T가 결정되면 자동으로 ParamType이 결정된다. 또는 역으로 ParamType이 결정되는 T를 결정할 수 있다.

단순 T

ParamType이 T 인경우는 단순하다. T는 입력되는 자료형이 되며 ParamType과 동일하다.

#include <iostream>

struct Box{};

template<typename T>
void f(T param) {
    std::cout << "f(" << typeid(T).name() <<" " << typeid(param).name() << ")\n";
}

int main() {
    Box box;
    f(box);   // (1) T: Box 
    const Box  box2;
    f(&box2); // (2) T: const Box \*
    f(Box()); // (3) T: Box
}

(1) box는 단순객체이므로 T와 ParamType은 Box가 된다.
(2) &box2로 호출한 경우는 T와 ParamType은 const Box*가 된다.
(3) Box()를 바로 호출한 경우도 단순 객체이므로 T와 ParamType은 Box가 된다

Note: typeid()에 의한 형식정보는 형식이름과 포인터인 경우 const인지 표시된다. 이 부분은 컴파일러마다 달라질 수 있다.

포인터 형식 T*

만약 ParamType이 T*으로 포인터형식으로 선언되었다면 어떻게 될까?

#include <iostream>
#include <typeinfo>

struct Box{};

template<typename T>
void f(T* param) {
    std::cout << "f(" << typeid(T).name() <<" " << typeid(param).name() << ")\n";
}

int main() {
    Box box;
    f(&box); // (1) T: Box

    const Box *box2 = &box;
    f(box2); // (2) T: const Box
}

인자가 포인터이기 때문에 포인터로 호출한 경우만 실행할 수 있다. 포인터로 호출한 경우 이미 인자에 포인터가 있기 때문에 T 형식은 포인터를 제외한 형식을 사용한다.

(1) &box을 호출하는 경우 ParamType은 Box* 타입이 되지만, 인자형식에 포인터가 있기에 포인터를 제거한 Box가 T의 형식이 된다.
(2) 이는 box2에서도 동일하게 const Box*가 인자 형식이 되지만 포인터를 제거한 const Box가 T의 형식이된다.

만약 ParamType이 const T* 형식으로 되어 있어도 동일하다. 단지 const Box*인 인자가 넘어오면 T 형식이 const와 포인터가 제거된 Box가 된다. const가 없는 Box* 인경우는 const은 고려하지 않고 포인터만 제거한 Box가 된다.

참조형식 T&

ParamType이 참조 형식인 T& 선언된 경우를 보자.

#include <iostream>

struct Box{};

template<typename T>
void f(T& param) {
    std::cout << "f(" << typeid(T).name() <<" " << typeid(param).name() << ")\n";
}

int main() {
    Box box;
    f(box);   // (1) T: Box
    const Box box2;
    const Box& box3 = box2;
    f(box2);  // (2) T: const Box
    f(box3);  // (3) T: const Box
    f(Box()); // (4) 에러
}

T&가 되면 왼값 참조가 되어서 오른값 참조는 받을 수 없을 뿐이고 T 형식에는 영향이 없다. 일반적인 참조 인자가 있는 함수와 동일하다. 참조 연산자는 있어도 없어도 동일(?)하며, 내부적으로 포인터를 받을지 객체 전체를 받을지에 대한 차이이다.

(1) 일반적인 객체인 box는 T가 Box가 되고 ParamType은 Box&가 된다.
(2) 일반적인 객체인 box2는 const도 같이 넘겨지며 T가 const Box가 되고 ParamType은 const Box&가 된다.
(3) box3 참조로 되어 있지만 인자로 넘겨질 때는 일반 변수로 넘겨지고 T는 const Box가 되고 ParamType은 const Box&가 된다.
(4) Box()은 오른값으로 입력 받을 수 없어 에러가 발생한다.

ParamType이 상수항을 붙인 const T& 형식인 경우 포인터의 경우와 동일하다. 인자 형식에 const가 있으면 제거되고 없으면 무시하면 된다. 단지 주의할 부분은 상수형 참조이므로 오른값이 입력 받을 수 있다.

보편참조 T&&

ParamType이 보편 참조인 T&&인 경우를 살펴보자.

#include <iostream>

struct Box{};

template<typename T>
void f(T&& param) {
    std::cout << "f(" << typeid(T).name() <<" " << typeid(param).name() << ")\n";
}

int main() {
    Box box;
    f(box); // (1) T: Box
    const Box box2;
    f(box2); // (2) T: const Box
    f(Box()); // (3) T: Box&
}

보편 참조이므로 인자 형식은 왼값이 넘어오는 경우 T& 형태로, 오른값이 넘어오는 경우 T&&형태로 사용된다고 보면 된다. 그리고 참조이기 때문에 T 형식에 참조가 유지된다고 하는데 필자의 입장에서는 무시할 수 있다고 본다.

(1) box는 왼값이므로 T& 형태가 되며 T는 Box가되고 ParamType은 Box&가 된다.
(2) box2도 왼값이므로 T& 형태가 되며 T는 const Box가 되고 ParamType은 const Box&가 된다.
(3) Box()가 넘겨지며 오른값이 되므로 T&&가 되며 T는 Box가 되고 ParamType은 Box&&가 된다.

만약 상수형인 const T&& 형식으로 된다면 이는 오른쪽 참조 한정이기에 왼값은 입력 받을 수 없다. 그리고 const가 있기 때문에 T 형식에서 const은 제거된다.

배열과 함수 포인터

함수와 배열을 함수 인자로 넘기는 경우 형식연역은 어떻게 될까?

#include <iostream>

template<typename T>
void f(T param) {
    std::cout << "f(" << typeid(T).name() <<" " << typeid(param).name() << ")\n";
}
void g() {}
int main() {
    const char s1[] = "Hello";
    const char *s2 = s1;
    f(s1); // (1) T: const char*
    f(s2);  // (2) T: const char*
    f(g); // (3) T: void (*)(void)
}

배열은 c에서 부터 포인터와 동일하게 취급한다.

(1) 배열은 T가 동일한 상수형 문자열 포인터인 const char*로 연역된다.
(2) 포인터는 그대로 포인터인 const char*로 연역된다.
(3) 인자가 함수인 경우 함수 포인터로 void(*)(void)가 된다.

auto 형식 연역

auto는 초기화 데이터의 자료형에 따라 결정되는 형식이다. 이때에 auto도 템플릿 처럼 형식연역이 발생한다. 변수의 형식 지정자(type specifier)에 의해 형식이 결정된다.

단순 auto

기본적인 auto 정의 형식을 살펴보자.

struct Box{};

int main() {
    const Box box;

    auto v1 = box;         // (1) auto: Box
    const auto v2 = box;   // (2) auto: Box
    auto v3 = Box();       // (3) auto: Box
    const auto v4 = Box(); // (4) auto: Box
}

일반적인 변수 선언과 동일하게 보면 된다. 초기화되는 타입에 따라 형식이 결정된다. const Box로 되어 있지만, auto 입장에서는 그냥 box객체가 할당되기 때문에 const가 아닌 경우와 동일하다.

(1) v1의 auto는 Box가 되고 형식은 Box가 된다.
(2) v2의 auto는 Box가 되고 형식은 const Box가 된다.
(3) v3의 auto는 Box가 되고 형식은 Box가 된다.
(4) v4의 auto는 Box가 되고 형식은 const Box가 된다.

당연하겠지만 할당 이후에는 서로 별도 메모리 공간을 가진다.

포인터 auto*

포인터 이므로 포인터 객체를 할당한다. 다른 객체는 할당할 수 없다.

struct Box{};

int main() {
    const Box box;

    auto *v1  = &box;      // (1) auto: const Box
    const auto *v2 = &box; // (2) auto: Box
}

포인터 변수에 포인터 객체를 할당한다. 이때 포인터를 넘겨주기에 같은 메모리 공간을 참조하게 된다.

(1) const가 없는 auto 이지만 const Box가 되고 형식은 const Box*가 된다.
(2) auto에 const형이 있어서, const Box에서 auto는 Box가 되고 형식은 const Box*가 된다.

당연하겠지만, 포인터 변수이므로 다른 포인터를 할당할 수 있다. const이기 때문에 포인터에 있는 메모리 값은 변경할 수 없다.

참조 auto&

참조는 일반적인 참조와 비슷하게 동작한다. 단지 형식영역만 있을 뿐이다.

struct Box{};

int main() {
    const Box box;

    auto& v1  = box;        // (1) auto: const Box
    const auto& v2 = box;   // (2) auto: Box
    auto& v3 = Box();       // (3) 에러
    const auto& v4 = Box(); // (4) auto: Box
}

개체를 참조하는 변수로 할당된다. 내부적으로 포인터 형태로 처리되기에 같은 메모리 공간을 사용한다.

(1) const가 없는 auto 이지만 auto은 const Box가 되고 형식은 cons Box가 된다.
(2) const auto 이며 auto는 Box가 되고 const로 인해 형식은 const Box가 된다.
(3) 왼값 참조로서 오른값은 할당할 수 없다.
(4) 상수형 참조는 오른값을 할당할 수 있다. 그래서 auto은 Box가 되고 const로 인해 형식은 const Box가 된다.

보편참조 auto&&

다음으로 auto를 보편 참조 형태로 선언하는 형태이다.

struct Box{ };

int main() {
    const Box box;

    auto&& v1  = box;        // (1) auto: const Box
    const auto&& v2 = box;   // (2) 에러
    auto&& v3 = Box();       // (3) auto: Box
    const auto&& v4 = Box(); // (4) auto: Box
}

auto를 보편참조로 선언할 수 있다. 보편참조도 일반 참조처럼 내부가 포인터 형태로 처리되기 때문에 같은 메모리 공간을 사용한다.

(1) const가 없는 auto지만, auto는 Box가 되고 형식은 왼값에 의해 const Box&가 된다.
(2) const auto&&는 보편 참조에서는 오른값만 할당가능하므로 에러가 발생한다.
(3) const가 없는 auto는 Box가 되며, v3에 오른값 할당으로 Box&& 형식을 가진다.
(4) const auto에서 auto는 Box가 되며 오른값 할당으로 const Box&& 형식이 된다.

중괄호 초기화

auto 초기화할 때에 종괄호 초기화를 신경써야 한다. 이부분이 템플릿과 다른 점이다.

#include <iostream>

using namespace std;

int main() {
    auto v1 = 10; // (1) 형식: int
    cout << "v1: " << typeid(v1).name() << endl;    
    auto v2(10);  // (2) 형식: int
    cout << "v2: " << typeid(v2).name() << endl;
    auto v3 = { 10 }; // (3) 형식: std::initializer_list
    cout << "v3: " << typeid(v3).name() << endl;
    auto v4{10}; // (4) 형식: int
    cout << "v4: " << typeid(v4).name() << endl;
}

중괄호 초기화는 내부적으로 std::initializer_list 형식을 사용한다.

(1) 기본 할당으로 형식으로 auto는 int가 된다.
(2) 괄호에 의한 생성자 초기화로 auto 형식은 int가 된다.
(3) 중괄호를 사용으로 std::initializer_list가 생성되고 할당되며 auto 형식은 std::initializer_list가 된다.
(4) 단일 항목이 있는 중괄호로 초기화해야 하며, 단일 값이 할당되면서 auto 형식은 값 형식인 int가 된다.

중괄호 초기화할 때에는 중괄호 안에 형식이 썩이면 안된다. 예를 들어 {1, 2.0}으로 초기화한다면 에러가 발생한다. 또한 auto에 생성 형태로 중괄호를 사용할 경우에 여러 구분으로 할당할 수 없다. 중괄호에는 한 개만 들어있어야 한다.

auto e1 = {1, 2.0}; // 에러
auto e2{1,2}; // 에러

결론

템플릿과 auto에서 형식 연역을 살펴보았다. 추가 확인 과정으로 인해 좀더 수정이 필요했다. 양해를 구합니다. 이미 템플릿에 익숙하다면 크게 어렵지 않다. 참조, 포인터, const에 의한 영향이 있기 때문에 주의가 필요하지만, 기존에 이미 익숙한 경우는 보편 참조에 대한 부분만 제외하면 크게 어렵지 않다. 그리고 보편 참조도 이해하고 본다면 보편 참조의 형식 연역도 크게 어렵지 않다.
사실 이를 제대로 인해하기위한 부분은 머리 속으로 설계하거나 디버깅할 때에 많은 도움이 된다. 잘 모르면 한번 작성해보고 테스트하면 된다. ㅡ.ㅡ;;;
제가 이해한 기준으로 정리한 부분이라 틀린 부분이 있을 수 있습니다. 혹시 틀린 부분이 있다면 저에게 메일로 알려주시면 감사하겠습니다. 여러분에게 도움이 되었으면 합니다. 즐프하세요. ospace.

참조

[1] 스콧 마이어스 저, 류광 역, Effective Modern C++, 인사이트

반응형