레이블이 Modern C✚✚인 게시물을 표시합니다. 모든 게시물 표시
레이블이 Modern C✚✚인 게시물을 표시합니다. 모든 게시물 표시

2017년 3월 31일 금요일

C++ Special Member Functions

 Special Member Function이라함은 컴파일러가 필요할 때 직접 생성해주는 멤버 함수를 말한다. 즉 아래 함수들이 Special Member Function들이다.
  • default constructor
  • destructor
  • copy constructor
  • copy assignment operator
  • move constructor
  • move assignment operator
 이 함수들은 사용자가 정의하지 않은 경우 컴파일러가 기본 구현을 제공하는 함수들이다. 하지만 항상 기본 구현이 제공되는 것은 아니고 몇 가지 조건이 만족될 때만 기본 구현을 제공한다. 이번 글에서는 그 규칙에 대해서 정리해보고자 한다.

1. 기본 생성자(default constructor)

 클래스에 생성자가 하나도 정의되어 있지 않은 경우에만 기본 생성자를 제공한다. 기본 생성자(default constructor)는 인자가 하나도 없는 생성자를 말한다. 클래스의 생성자가 단 하나도 정의되지 않은 경우에는 컴파일러가 기본 생성자를 제공하는데 제공되는 기본 생성자의 구체적인 구현은 각 멤버의 기본 생성자가 호출되는 것과 같다. 따라서 컴파일러가 생성하는 기본 생성자를 이용하려면 클래스의 모든 멤버들도 역시 기본 생성자를 제공해야 한다는 조건이 하나 더 붙는다.

2. 소멸자(destructor)

 클래스에 소멸자가 정의되어있지 않으면 컴파일러가 자동으로 기본 구현을 제공한다. 상속받은 부모 클래스의 소멸자가 virtual인 경우에는 마찬가지로 virtual로 선언된 소멸자를 제공하고 그렇지 않은 경우에는 non-virtual이다. 그리고 디폴트로 noexcept 소멸자를 제공한다.

3. 복사 생성자(copy constructor)

 클래스에 복사 생성자(copy constructor)가 정의되어 있지 않으면 컴파일러가 기본 구현을 제공한다. 하지만 제공되는 구현이 경우에 따라 다르다.
  1. 이동 생성자 또는 이동 대입 연산자가 선언되어 있으면 복사 생성자는 delete된 선언이 제공된다.
  2.  이동 생성자 또는 이동 대입 연산자가 선언되어 있고 복사 생성자가 선언되지 않았을 때는 컴파일러가 복사 생성자를 delete로 선언한다. 즉, delete된 구현을 제공하는 것이다. 이 상태에서 복사 생성자를 호출하는 코드가 어딘가에 존재한다면 컴파일러는 delete된 함수를 호출한다는 에러를 발생시킬 것이다.
  3. 소멸자 또는 복사 대입 연산자가 선언되어있으면 컴파일러가 복사 생성자를 제공하지만 이 기능은 deprecated되었다.
  4.  뭔가 말이 안되는 것 같지만 설명을 해 보자면, 이런 경우 컴파일러가 각 멤버를 복사 생성하는 구현을 제공하긴 하지만 이렇게 기본 구현을 제공하는 기능은 deprecated되었다는 것이다. 이런 저런 환경에서 개발을 하다보면 오래된 API들이 deprecated된 경우를 볼 수 있을 것이다. 호환성때문에 존재하지만 언젠가는 사라질 수 있는 API들을 그런 상태로 선언해 놓는다. 마찬가지로 이러한 컴파일러의 기능도 C++표준에서는 사용하지 말도록 권고하는 것이며 직접 구현을 제공할 것을 권고하는 것이다.
  5. 그 외에는 복사 생성자가 선언되지 않았다면 컴파일러가 기본 구현을 제공한다.

4. 복사 대입 연산자(copy assignment operator)

 클래스에 복사 대입 연산자(copy assignment operator)가 정의되어 있지 않으면 컴파일러가 기본 구현을 제공한다. 복사 생성자와 복사 대입 연산자가 서로 대칭될 뿐 같은 규칙으로 제공된다.
  1. 이동 생성자 또는 이동 대입 연산자가 선언되어 있으면 복사 대입 연산자는 delete된 선언이 제공된다.
  2.  이 규칙은 복사 생성자와 같다.
  3. 소멸자 또는 복사 생성자가 선언되어 있으면 컴파일러가 복사 대입 연산자를 제공하지만 이 기능은 deprecated되었다.
  4.  복사 생성자 대신 복사 대입 연산자가 되었을 뿐 규칙은 복사 생성자의 경우와 같다. 즉 제공은 되지만 사용을 하지 않는 게 좋다.
  5. 그 외에는 복사 대입 연산자가 선언되지 않았다면 컴파일러가 기본 구현을 제공한다.

5. 이동 생성자(move constructor)

 이동 생성자(move constructor)는 소멸자, 복사 생성자, 복사 대입 연산자, 이동 생성자, 이동 대입 연산자가 모두 선언되지 않았을 때만 기본 구현이 제공된다. 기본 구현은 각 멤버별로 이동 생성자가 호출되는 것과 같다.

6. 이동 대입 연산자(move assignment operator)

 이동 대입 연산자(move assignment operator)는 소멸자, 복사 생성자, 복사 대입 연산자, 이동 생성자, 이동 대입 연산자가 모두 선언되지 않았을 때만 기본 구현이 제공된다. 즉, 이동 생성자와 같은 규칙이다. 기본 구현은 각 멤버별로 이동 대입 연산자가 호출되는 것과 같다.


 C++98 시대의 C++ 개발자들에게는 3의 규칙이라는 것이 있어서 소멸자, 이동 생성자, 이동 대입 연산자 중에 하나라도 직접 선언했다면 나머지 두 개도 직접 선언해야 한다는 암묵적 규칙이 있었다. 3개 함수중에 하나라도 기본 제공되는 구현이 적당하지 않다면 나머지 2개도 적당하지 않을 확률이 매우 높기 때문에 생긴 규칙이다. 하지만 위 규칙에서 보듯이 암묵적일 뿐 컴파일러 강제사항은 아니다.

 C++11이 되면서 move semantics 개념이 도입되어 이동 연산이 추가되고 3의 규칙은 이동 함수들에도 확장되었다. (5의 규칙?) 하지만 과거의 코드들과 호환성을 유지해야 할 필요성 때문에 이동 함수들만 5개 함수들 중 하나라도 선언되어 있으면 자동 생성되지 않도록 결정되었고 나머지는 거의 그대로 유지가 되고 있다. 복사 함수들이 이동 함수들이 선언된 경우에는 delete로 처리되는 것도 3의 규칙의 확장선 상의 규칙이며 C++11에서 추가되었는데 이것은 이동 함수들이 선언된 경우이므로 C++98 시대의 코드에는 영향을 미치지 않기 때문이다. 3의 규칙과 달리 확장된 규칙들은 컴파일러 강제사항이기 때문에 직접 처리할 수 밖에 없다.

 그렇다면 virtual 소멸자가 필요해서 소멸자를 선언했을 뿐인데 다른 모든 special member function들의 기본 구현을 이용할 수 없게 된다면 어떨까? 이 경우 개발자는 확실히 이것이 불편하다고 느낄 것이다. 이런 경우를 위해서 기본 구현을 간단히 선언할 수 있는 방법이 있다.
class Sample {
public:
  virtual ~Sample() = default; // default destructor

  Sample(const Sample&) = default;            // default copy constructor
  Sample& operator=(const Sample&) = default; // default copy assignment

  Sample(Sample&&) = default;            // default move constructor
  Sample& operator=(Sample&&) = default; // default move assignment

 
  ...
};
함수의 구현을 제공하는 대신 선언부에 '= default'를 붙여주면 컴파일러가 제공하는 기본 구현이 그대로 적용된다. 위의 규칙 중 3번, 4번에서 deprecated된 기능을 사용하지 않고 기본 구현을 사용하는 간단한 방법으로 이렇게 default 선언을 사용하면 사용자가 직접 함수를 선언한 것이 되므로 deprecated된 구현을 사용하는게 아니며 추후에 생길 수 있는 문제를 피할 수 있다. 또한 default 구현을 이용하겠다는 뜻으로 이렇게 명시적으로 선언하는 것은 좋은 습관이다.

2017년 3월 9일 목요일

C++ deleted functions (함수의 삭제 선언)

 C++11에 새롭게 추가된 함수의 삭제(delete)에 대해서 정리해보려고 한다. 어떤 함수를 삭제(delete)시키면 그 함수에 대한 어떤 호출도 불가능하게 된다. 물론 함수를 사용하지 못하게 할 거라면 그냥 함수를 작성하지 않으면 된다. 하지만 문제가 발생하는 함수들은 컴파일러가 자동으로 생성해주는 함수들이다. C++ 컴파일러는 클래스의 생성자, 소멸자, 대입연산자 등을 필요에 의하여 자동으로 생성해주는 기능이 있다. 하지만 개발자는 일부러 그런 함수들을 사용하지 못하게 하려고 만들지 않았는데 컴파일러가 알아서 생성해 준다면 꽤 귀찮은 문제가 된다. 이럴 때 사용하는 방법이 함수를 delete시키는 것이다.

 C++11 이전에는 그런 메소드를 private으로 선언함으로써 다른 개발자들이 사용할 수 없도록 하였으나 C++11에서는 아래 예와 같이 함수를 delete시키는 방법을 제공해 준다.
class NoCopyClass {
public:
    ...
    NoCopyClass(const NoCopyClass&) = delete;
    NoCopyClass& operator=(const NoCopyClass&) = delete;
    ...
};
 복사 생성자와 복사 대입 연산자를 삭제함으로써 클래스의 객체를 복사할 수 없게하려고 한다는 것을 짐작할 수 있다.(이렇게 의도가 전달되는 것은 좋은 일이다.) 이런 생성자와 연산자들은 컴파일러가 기본 구현을 제공하는 것들이지만 개발자가 직접 삭제 함수(deleted function)로 구현한 것이기 때문에 컴파일러도 기본 구현을 제공하지 않고 다른 개발자들이 호출할 수도 없게 되는 것이다.

 참고로 함수의 삭제 선언은 public으로 선언하는 것이 관례이다. 왜냐하면 컴파일러가 함수의 삭제여부 이전에 private여부를 먼저 판단하기 때문에 삭제된 함수에 대한 정확한 메시지를 출력하도록 유도하려면 public으로 선언해야하기 때문이다.

 함수의 삭제는 private으로 선언하는 방법보다 좋은 점이 몇 가지 있는데 클래스의 멤버 함수 외에 모든 함수가 삭제 가능하다는 점이다. 이러한 점을 이용해서 "함수 인자의 암묵적인 변환을 이용한 호출"을 막을 수도 있다. 아래 예에서 isLucky()함수는 정수형을 받는 함수지만 암묵적 변환이 가능한 인자들을 넣고 호출해도 모두 호출이 된다. 하지만 호출을 막고자 하는 타입을 받는 overload 함수를 delete로 선언해버리면 암묵적 변환 전에 완벽하게 매칭되는 함수가 호출되는 것이 우선이므로 삭제된 함수를 호출하게 되고 따라서 호출 불가 판정을 하게 되는 것이다.
bool isLucky(int number);
bool isLucky(double) = delete;

if (isLucky(7)) // 호출 가능
    ...
if (isLucky('a')) // 호출 가능
    ...
if (isLucky(3.5)) // 호출 불가능. 에러!
    ...
 double 타입 인자를 받는 isLucky() 함수가 선언되지 않았으면 3.5는 int형으로 변환되어 호출이 되었을 것이다. 하지만 double 인자 버전이 delete로 선언되었기 때문에 삭제된 함수에 매칭이 되어 호출이 되지 않고 에러를 발생시킨다. 알아두면 가끔 유용하게 쓰일 것 같다.

 함수의 삭제는 템플릿의 특정 특수화(specialization) 버전을 삭제함으로써 그 함수가 생성되는 것을 막는데 이용할 수도 있다. 함수 템플릿은 컴파일러가 추론된 타입에 맞춰서 그때그때 생성하게 되는데 특정 타입에 대한 함수를 생성하지 못하도록 하려고 할 때 미리 사용을 막고자 하는 타입의 특수화 버전을 delete로 선언해버리면 컴파일러는 특수화 함수가 존재하므로 코드를 생성하지 않고 삭제된 함수이므로 호출도 하지 못하게 되는 것이다. 아래 예를 보면 어떤 상황인지 어렵지 않게 이해할 수 있다.
template<typename T>
void processPointer(T* ptr);

template<>
void processPointer<void>(void*) = delete; // void 포인터는 호출 불가!
 void 타입의 특수화 함수를 선언하고 삭제함으로써 그런 함수가 생성되는 걸 막고 당연히 호출도 불가하게 만드는 것이다.

 위와 같이 함수의 delete 선언이 주는 장점은 여러가지가 있지만 사실 가장 큰 장점은 코드의 의도를 분명히 할 수 있다는 데 있다. 특정 메소드를 사용하지 못하게 하려고 private의 선언하는 것보다 delete로 선언하는 게 더 분명하게 의도를 전달할 것이고 그런 코드를 읽게 된다면 무엇을 의도하고 함수를 삭제했는지 파악하는 것도 private으로 선언하는 것보다 훨씬 쉬울 것이다. 코드를 읽기 쉽게, 의도를 분명하게 작성하는 건 기본 중의 기본이고 항상 바람직한 일이다.

2017년 3월 6일 월요일

C++ Unscoped enumerations VS. Scoped enumerations

 C++11에 새로 추가된 것들 중에 enum class라는 것이 있다. 원래부터 있었던 enum과 유사하지만 좀 더 발전된 것이라고 할 수 있겠다. Effective Modern C++에서는 원래 있던 enum을 unscoped enum으로 새로 추가된 enum class를 scoped enum으로 구별해서 부른다. 선언 방식은 아래 예와 같다.
enum Color { black, white, red }; // unscoped enum
int white; // 에러
enum class Color { black, white, red }; // scoped enum
int white; // 문제 없음
 선언 방법에 있어서 scoped enum의 경우 "enum class"를 사용한다는 점이 이전과 달라진 점이다. 두 가지 방식을 scoped와 unscoped로 부르는 이유는 말 그대로 black, white, red의 namespace 범위가 두 경우에 대해 다르게 적용되기 때문이다. 위 예를 가지고 설명해 보자면 unscoped의 경우에는 black, white 등의 이름은 Color와 같은 namespace를 갖는다. 즉, Color라는 이름이 유효한 공간에서는 black, white 등의 이름도 모두 유효하다. 따라서 다른 변수나 함수 이름 등으로 사용할 수가 없다. 하지만 scoped의 경우에는 black, white 등의 이름이 Color 클래스 내부에서만 유효하다. 즉, Color의 중괄호 {} 안에서만 유효하므로 그 밖에서는 그 이름을 다른 변수나 함수 이름으로 사용하는 데 아무런 문제가 없다. 물론 enum값을 사용할 때 Color::black과 같이 사용해야 하는 게 불편할 수도 있지만 내가 보기엔 더 명확하고 좋은 것 같다.

 사실 이렇게 namespace가 오염(?)된다는 것만으로 scoped enum을 사용하라고 한다면 그다지 크게 공감이 가진 않는다. Effective Modern C++에서는 몇 가지 장점을 더 제시하고 있다. 그 중 한 가지가 scoped enum의 경우에는 전방 선언(forward declaration)이 가능하다는 것이다. unscoped enum의 경우에는 전방선언을 할 수 없었지만 scoped enum의 경우 이게 가능해지면서 소스 파일간의 dependency를 줄여서 헤더 파일을 약간 수정함으로써 생기는 많은 소스 코드의 재컴파일을 줄일 수 있다는 장점이 있다. C++로 정말 큰 프로젝트를 수행한다면 빌드 타임을 줄일 수 있다는 점은 큰 메리트가 될 것이다. 하지만 사실 이 점도 나에게는 그렇게 크게 공감가는 부분은 아니다. 대부분 큰 프로젝트를 수행하다보면 어느 정도 작은 크기의 모듈로 나누어지게 마련이고 그러다보면 한 번 빌드하는데 5분이상 걸리는 경우를 만나는 일도 별로 없기 때문이다. 게다가 unscoped enum도 약간의 수고를 더한다면 전방선언이 가능하기 때문에 별로 큰 장점은 아니다. 이 글을 계속 읽어보면 전방선언이 가능하다는 걸 알게 될 것이다.

 Effective Modern C++이 제시하는 장점 중에 내가 생각하는 가장 큰 장점은 타입 체크를 더 강하게 한다는 것이다. Java 같은 언어에서 enum의 타입은 class와 동등하게 엄격한 타입 검사가 적용된다. 하지만 C++에서 (unscoped) enum은 그냥 정수형처럼 사용해도 아무 제지를 받지 않는다. 심지어 float같은 상수와 연산을 해도 암묵적 변환이 자동으로 이루어진다. 물론 이게 편하다고 생각하는 개발자도 있겠지만 적어도 바람직하지 않은 것만은 사실이다. 하지만 C++11에서 추가된 scoped enum의 경우에는 정수형으로의 암묵적 변환이 허용되지 않는다. 필요하다면 개발자가 암묵적이 아닌 직접적인 캐스팅을 해주어야 문제가 없다. 이런 방식이 개발자의 실수를 줄이고 이해하기 쉬운 코드를 만드는데 훨씬 유리하다고 나는 확신한다. Java같은 최신 언어들로부터 느낀 많은 장점들이 C++에도 조금씩 조금씩 적용된다는 건 바람직한 일이 아닐 수 없다.

 많은 개발자들이 신경쓰지 않고 사용했겠지만 enum을 선언할 때 개발자가 직접 기초 타입(underlying type)을 지정할 수 있다. 아래 예에서 보듯이 unscoped와 scoped 둘 다 지정이 가능하다.
 // unscoped enum
enum Status: std::uint32_t { good = 0,
                             failed = 1,
                             incomplete = 100,
                             corrupt = 200,
                             audited = 500,
                             indeterminate = 0xFFFFFFFF
                           };

// scoped enum
enum class Status: std::uint32_t { good = 0,
                                   failed = 1,
                                   incomplete = 100,
                                   corrupt = 200,
                                   audited = 500,
                                   indeterminate = 0xFFFFFFFF
                                 };

 위의 예처럼 기초 타입을 직접 지정하는 경우에는 unscoped enum의 경우에도 전방 선언이 가능하다. 전방 선언이 허용되지 않았던 이유는 enum의 기초 타입이 지정되지 않았을 때는 컴파일러가 enum의 정의를 보고 적당한 크기의 정수형(integral) 타입을 임의로 결정하여 사용하기 때문에 기초 타입이 지정되지 않은 전방 선언 만으로는 그 크기를 알 수 없으므로 전방선언을 허용하지 않았던 것이다. 하지만 scoped enum의 경우에는 직접 지정하지 않았을 때 default로 int형을 사용하게 되어있다. 더 작거나 큰 타입을 사용하고 싶다면 직접 지정해서 사용하면 된다.

 참고로 unscoped enum이 더 편리한 경우를 들어보자면 array나 std::tuple의 인덱스로 사용할 정수형 상수(constant)를 enum으로 선언해놓고 사용할 때이다. 이 경우에는 casting이 필요없는 unscoped enum이 더 사용하기 편리하다. 아래 두 버전의 차이를 보자.
using UserInfo = std::tuple<std::string,  // name
                            std::string,  // email
                            std::size_t>; // reputation

// unscoped enum
enum UserInfoFields { uiName, uiEmail, uiReputation };

UserInfo uInfo;

...

auto val = std::get<uiEmail>(uInfo); // emil 필드의 값 얻기
// scoped enum
enum class UserInfoFields { uiName, uiEmail, uiReputation };

UserInfo uInfo;

...

auto val = std::get<static_cast<std::size_t>(UserInfoFields::uiEmail)>(uInfo); // 캐스팅 필요
 굳이 추가 설명을 하지 않아도 unscoped 버전이 훨씬 가독성이 좋고 타이핑해야 할 코드도 짧다. 게다가 이런 식의 사용은 많은 개발자들이 애용하는 방법이기때문에 unscoped enum의 장점을 무시할 수도 없다.

 unscoped enum과 scoped enum을 비교했을 때 정말 치명적인 장단점이 있다고는 생각치 않는다. unscoped enum이 전혀 불편함이 없고 익숙하다면 뭐 그리 대단히 사용하지 말아야 할 이유가 있는 건 아니라는 뜻이다. 하지만 내 의견은 scoped enum을 사용하는 것이 개발자의 실수를 줄이고 코드의 의도를 더 분명히 할 수 있는 더 좋은 습관이라고 생각한다. 그래서 C++표준에 추가된 것일 테고 새로운 세대의 개발자들이 scoped enum을 사용해주길 기대하고 있을 거라 생각한다.

2017년 3월 2일 목요일

C++ type alias : 'using'

 C++11 이전의 C++ 개발자들이 자주 사용하던 using 키워드의 용도는 namespace를 계속 입력하는 게 귀찮아서 STL 사용 시 아래와 같은 선언을 코드의 앞 부분에 추가해주고 "std::"를 생략하기 위함일 것이다. (내 경우에는 그랬다)
using namespace std;
...
 C++11에서 using의 기능을 새롭게 추가하였는데 그것이 타입 별칭(Type alias)을 선언하는 것이다. 기존에 typedef라는 키워드를 이용해서 STL 등에서 제공하는 긴 iterator 타입을 간단하게 선언해서 사용해 본 경험이 많이들 있을 거라고 생각한다. using을 이용한 타입 별칭 선언은 typedef가 하는 일과 마찬가지로 기존 타입을 새로운 이름으로 선언하는 것이다. 아래 함수 포인터 타입을 선언하는 2가지 예를 보면서 어떤 게 더 보기 좋은지 한번 생각해보자.
typedef void (*FP)(int, const std::string&);  // typedef 이용
using FP = void (*)(int, const std::string&); // using 이용
 다른 건 몰라도 함수 포인터 타입을 선언할 때는 using이 훨씬 분명하게 뜻이 전달되는 것 같다. 하지만 이것만이 기존 typedef를 사용하던 습관을 버리고 using을 사용해야 할 이유라면 아마도 typedef가 익숙한 개발자에게는 별로 납득할만한 이유는 되지 못할 것 같다. Effective Modern C++책 에서는 typedef보다는 using을 사용하라고 권장하며 그 이유에 대해서도 설명하고 있는데 여기서 그 이유에 대해 정리를 해 보려고 한다.

 using을 이용한 타입 선언이 typedef와 크게 구별되는 점이 하나 있다. using의 경우에는 템플릿화가 되지만 typedef는 그렇지 못하다는 점이다. 아래 예를 보자.
template<typename T> // std::list<T, MyAlloc<T>>의 별칭은 MyAllocList<T>
using MyAllocList = std::list<T, MyAlloc<T>>;

MyAllocList<Widget> lw; // client code
 C++11에서는 이렇게 using을 이용해서 간단히 T에 따라 변화하는 템플릿 타입의 별칭을 선언할 수 있지만 기존 typedef의 경우에는 템플릿화할 수 없기 때문에 이러한 목적을 이루기 위해서 아래 예와 같은 방법을 사용하였다.
template<typename T> // std::list<T, MyAlloc<T>>의 별칭은 MyAllocList<T>::type
struct MyAllocList {
  typedef std::list<T, MyAlloc<T>> type;
};

MyAllocList<Widget>::type lw; // client code
 템플릿 구조체를 하나 선언해서 그 안에서 typedef를 이용하여 별칭을 선언하는 것이다. 이렇게 함으로써 실제 타입을 사용할 때 "::type"을 덧붙인다는 소소한 불편함을 제외하면 같은 동작을 하도록 할 수 있었다. 하지만 이렇게 선언된 타입은 템플릿 안에서 사용할 때 또 다른 불편함이 발생한다. 아래 예를 보자.
template<typename T>
class Widget {
private:
  typename MyAllocList<T>::type list; // MyAllocList<T>를 멤버로 선언
  ...
};
 MyAllocList::type 타입의 멤버를 선언하는 데 그 앞에 typename이라는 키워드를 써주어야 하는 불편함이 생긴다. 컴파일러가 MyAllocList::type을 만났을 때 이게 타입 이름인지 아니면 MyAllocList의 데이터 멤버인지 알 수가 없기 때문에 typename이라고 앞에 지정함으로써 타입 이름이라고 알려주는 것이다. using을 이용한 타입 별칭 선언은 항상 타입이라는 게 확실하기때문에 아래 예에서 보듯이 typename을 따로 지정하지 않는다.

template<typename T>
using MyAllocList = std::list<T, MyAlloc<T>>;

template<typename T>
class Widget {
private:
  MyAllocList<T> list;// "typename", "::type"이 없다.
  ...
};

 참고로 한 가지 더 정리하자면 using을 이용한 타입의 별칭 선언은 C++11에서 추가되었지만 STL의 많은 코드는 이전과의 호환성 때문에 이전 방식인 구조체 안에 type을 선언하는 방식을 그대로 유지하고 있다. 하지만 C++14에서는 이전보다 더 간단하게 사용할 수 있도록 같은 기능을 제공하는 템플릿 선언들을 추가로 제공한다. 아래 예에서 보듯이 기존 구조체 이름에 '_t'를 붙여서 같은 기능을 제공하도록 선언하였다.
std::remove_const<T>::type // C++11: const T → T
std::remove_const_t<T>     // C++14 버전

std::remove_reference<T>::type // C++11: T&/T&& → T
std::remove_reference_t<T>     // C++14 버전

std::add_lvalue_reference<T>::type // C++11: T → T&
std::add_lvalue_reference_t<T>     // C++14 버전
 위 예는 헤더파일에서 제공하는 선언들이다. 새롭게 추가된 선언들의 이름이 무엇인지 어렵지 않게 예상할 수 있을 것으로 생각된다.

2017년 2월 27일 월요일

decltype에 대하여

 Effective Modern C++에는 decltype에 대한 설명이 잘 나와있다. 책을 읽기 전까지는 언제 이런 키워드가 사용되는지 궁금할 정도로 거의 쓰이지 않을 것 같았는데 이 책을 읽고나니 나름 쓰임새가 있다는 것을 알 수 있었고 그 동작에 대해서도 깊게 이해할 수 있었던 것 같다.

 decltype은 declared type의 줄임말이다. 즉 선언된 타입을 알려주는 키워드이다. 이것도 템플릿이나 auto 키워드 처럼 컴파일타임에 유효한 것이지 런타임에 동적으로 타입을 식별해서 알려주는 것은 아니니 decltype을 이용해서 런타임에 동적으로 뭔가 하려고 했다면 생각을 바꿔야할 것이다. 최근에 나오는 많은 프로그래밍 언어들이 런타임에 동적으로 타입을 체크할 수 있는 기능들을 대부분 제공하다보니 decltype에 대해서 오해할 소지가 있기에 컴파일 타임에 정적으로 타입이 결정되어 사용된다는 것을 확실히 하고 가는 것이 좋겠다.

 먼저 간단히 아래 코드의 주석을 참고하면 decltype이 어떻게 동작할 지 알 수 있을 것이다. 사실 별로 예상과 어긋나는 동작은 없다.
const int i = 0; // decltype(i)는 const int

bool f(const Widget& w); // decltype(w)는 const Widget&
                         // decltype(f)는 bool(const Widget&)
struct Point {
  int x, y;   // decltype(Point::x)는 int
};            // decltype(Point::y)는 int

Widget w;     // decltype(w)는 Widget

if (f(w)) ... // decltype(f(w))는 bool

template<typename T> // std::vector의 단순화 버전
class vector {
public:
  ...
  T& operator[](std::size_t index);
  ...
};

vector<int> v; // decltype(v)는 vector<int>
...

if (v[0] == 0) ... // decltype(v[0])는 int&. 일반적으로 []연산자가 반환하는 타입은 참조형임
 이렇게 보니 decltype의 동작은 별로 예상과 다르지 않다. 게다가 별로 쓰일만한 곳도 없어 보인다. 일반적으로 컴파일러가 알아서 타입을 추론하도록 할 때는 auto 키워드가 쓰이기 때문에 decltype으로 타입을 알아내야 할 때가 있을까 싶기도 하다.

 책에서 말하는 decltype의 대표적인 쓰임새는 함수 템플릿의 반환값이 템플릿 인자에 의존하여 타입이 결정되지 않은 값을 반환해야할 때이다. 아래 예제를 보자. 이 예제는 일반적인 컨테이너의 []연산자와 같은 동작을 하는 함수를 구현한 것인데 값을 반환하기 전에 사용자 인증을 수행한다는 가정하에 구현된 함수이다.
template<typename Container, typename Index> // 완벽한 구현은 아니지만 뒤에 보완할 것임.
auto authAndAccess(Container& c, Index i)
  -> decltype(c[i])
{
  authenticateUser();
  return c[i];
}
 함수의 이름 앞에 auto가 지정되어 있다. 사실 이런 auto는 타입 추론과 아무 상관이 없으며 trailing return type 문법이 사용된다는 표시일 뿐이다. 이렇게 trailing return type 방식을 사용하면 함수의 인자들을 return 타입을 지정할 때에 이용할 수 있다는 장점이 있다. 위 예에서는 return 타입으로 decltype(c[i])를 사용함으로써 템플릿 인자 Container와 Index의 타입에 상관없이 return 타입을 지정할 수 있었다. 이런 식으로 decltype이 사용되는 것이다. 참고로 일반적인 컨테이너 c의 원소 타입이 T라면 함수의 return 타입은 T&이 된다.

 여기서 전에 올린 auto 타입 추론에서 설명했던 것처럼 C++14에서는 함수의 return 타입으로 auto를 지정하여 타입 추론이 되도록 할 수 있으므로 C++14에서는 아래와 같이 수정할 수 있다.
template<typename Container, typename Index>
auto authAndAccess(Container& c, Index i)  // C++14. 사실 제대로된 구현은 아님.
{
  authenticateUser();
  return c[i]; // return 타입은 c[i]로부터 추론된다.
}
 이렇게 수정하면 C++14 컴파일러는 함수의 return문을 분석하여 타입 추론을 수행한다. auto 타입 추론에서 설명했듯이 이 경우 타입 추론은 템플릿 타입 추론과 같은 방식으로 이루어지며 따라서 c[i]의 referenceness가 제거되어 타입이 결정된다. 즉 c[i]의 타입은 T&지만 함수의 return 타입은 T로 추론되는 것이다. 일반적으로 []연산자는 원소의 참조형을 반환해야 하는데 위 구현은 원소의 복사본이 반환되게 된다.

 C++14에서는 이런 경우를 극복할 수 있도록 decltype(auto)를 사용하도록 하였다. 아래 예제를 보자.
template<typename Container, typename Index>
decltype(auto) authAndAccess(Container& c, Index i)  // C++14.
{
  authenticateUser();
  return c[i]; 
}
 decltype(auto)를 return타입으로 지정하면 반환되는 표현식을 그대로 decltype에 넘긴 것과 같은 타입으로 추론된다. 따라서 decltype(c[i])의 타입 그대로 참조형(T&)을 반환하게 되는 것이다. 이런 식의 타입 추론을 "decltype 타입 추론"이라 한다.

책에서는 따로 설명하지 않았지만 위 예제의 경우 항상 참조형을 반환해야 한다면 decltype(auto)대신 auto&를 지정해도 문제가 되지 않는다. 하지만 혹시 c[i]가 참조형이 아니라 비-참조형 값이라면 또 다시 문제가 발생하게 된다. 왜냐하면 Container의 []연산자가 특별히 비-참조형을 반환하도록 설계되어 있다면 반환되는 값은 c[i]의 복사본이 될 텐데 복사본의 참조형을 반환하는 건 위험한 일이기 때문이다. 역시 decltype(auto)가 참조형을 반환하던 비-참조형을 반환하던 상관없이 문제를 일으키지 않는 유일한 해결책이다.

decltype(auto)는 함수의 리턴 타입에서만 사용되는 것은 아니다. 아래 예제에서 처럼 일반 변수의 선언에서도 사용될 수 있다.
Widget w;
const Widget& cw = w;

auto myWidget1 = cw;           // auto 타입 추론:
                               // myWidget1은 Widget

decltype(auto) myWidget2 = cw; // decltype 타입 추론:
                               // myWidget2는 const Widget&
 참고로 책에서는 위의 authAndAccess() 예제가 rvalue에 대해서도 완전하게 동작하려면 아래 예와 같이 Universal 참조와 std::forward를 사용하여 구현해야 한다고 설명하지만 decltype(auto)에 추가적인 의미를 부여하는 것은 아니기 때문에 설명은 따로 하지 않겠다.
template<typename Container, typename Index> // final C++14 version
decltype(auto) authAndAccess(Container&& c, Index i)
{
  authenticateUser();
  return std::forward<Container>(c)[i];
}

 decltype(auto)에 대해 한 가지 더 주의해야 할 것이 있다. 대부분 decltype이 개발자의 예상대로 동작하지만 개발자의 예상을 종종 빗나가는 경우가 있다. 일반적인 변수의 이름에 decltype을 적용하면 실제 그 변수가 선언된 타입이 도출된다. 이 경우는 아무런 문제가 없다. 그러나 단순 이름이 아닌 "lvalue 표현식"을 decltype에 넘겼을 때 나오는 타입이 문제가 된다. 이 경우 lvalue 표현식이 평가되는 타입은 lvalue 참조형이다. 실제로 lvalue 표현식의 타입은 lvalue 참조형이 맞기 때문에 사실 잘못된 건 아니다. 하지만 아래 예를 보면 충분히 헷갈릴만 한 경우가 있음을 알 수 있다.
int x = 0;

decltype(auto) a = x;   // a는 int
decltype(auto) b = (x); // b은 int&. "(x)"는 lvalue 표현식이기 때문에

decltype(auto) f1()
{
  int x = 0;
  ...
  return x; // decltype(x)은 int, 따라서 f1은 int형 반환
}

decltype(auto) f2()
{
  int x = 0;
  ...
  return (x); // decltype((x))은 int&, 따라서 f2는 int& 반환
}
위 예제에서 보듯이 단순한 이름이 아닌 lvalue 표현식의 타입은 lvalue 참조형으로 평가되기 때문에 "(x)"가 lvalue 참조형으로 평가되는 것이다. 이것이 f1과 f2의 커다란 차이를 만들게 된다. 이미 짐작하고 있겠지만 f2는 지역 변수의 참조형을 반환하는 위험한 동작을 하는 잘못된 함수라는 것이다. 이 처럼 decltype(auto)를 사용할 때 주어지는 표현식은 주의깊게 작성해야 함을 기억하기 바란다.

 이렇게 C++11, C++14 버전에서 새롭게 추가된 타입 추론의 동작에 대해서 정리를 해보았다. 혹시 이전 내용이 궁금하다면 아래 링크를 참고하기 바란다.

  1. 템플릿 타입 추론
  2. auto 타입 추론

2017년 2월 23일 목요일

auto Type Deduction (auto 타입 추론)

 이번 글은 C++11에 새롭게 추가된 auto 키워드에 관해 이야기해 보려고 한다. auto라는 단어가 뜻하는 것 처럼 누군가 자동으로 무엇인가를 해줄 것이라는 걸 예상할 것이다. 그렇다. auto 키워드는 타입이 들어갈 자리에 대신 들어가서 (템플릿에서 그랬던 것처럼) 컴파일러가 주변 코드를 이용해서 타입을 대신 결정하도록 하는 키워드이다. Effective Modern C++에서도 역시 심도있게 다루고 있으며 그 내용을 기반으로 내가 이해하는 바를 여기서 기록하고자 한다.

 auto가 수행하는 작업이 곧 템플릿 인자의 타입을 결정하는 것과 거의 같기 때문에 템플릿 타입 추론에 관해서 잘 모르는 상태라면 이번 글을 읽기 전에 이전에 올린 "템플릿 타입 추론"에 관한 글을 읽고 이해해 두는 것이 좋을 것 같다.

 그럼 먼저 auto의 타입 추론과 템플릿의 타입 추론이 얼마나 같은지(사실 약간 다르다) 간단한 설명을 보도록 하자. 먼저 템플릿 함수의 전형적인 예는 아래와 같다.
template<typename T>
void f(T param);

f(expr); // expr은 임의의 표현식
 일반적인 auto 선언문은 아래와 같은 형태이다.
auto x = expr; // expr은 임의의 표현식
 컴파일러는 템플릿 함수 f()에 넘기는 인자 expr의 타입에 기초하여 T의 타입 추론을 수행한다. 이 때는 "템플릿 타입 추론" 방식으로 동작하게 되고 그 동작에 대한 설명은 여기를 참조하기 바란다. 자 그 다음 auto의 예를 보면 바로 느낌이 올 것이다. auto에서도 역시 expr의 타입에 기초하여 타입 추론을 수행하는데 그 규칙은 템플릿의 그것과 동일하다. 즉, auto가 템플릿의 T역할을 하는 것이다.

 간단히 몇 가지 비교를 해보면 이해가 더 쉬울 것이다. 아래 예를 보자.
template<typename T>
void f1(T param);

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

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

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

auto a1 = expr;        // f1(expr)과 같은 타입 추론 수행
auto& a2 = expr;       // f2(expr)과 같은 타입 추론 수행
const auto& a3 = expr; // f3(expr)과 같은 타입 추론 수행
auto&& a4 = expr;      // f4(expr)과 같은 타입 추론 수행
 위 예에서 auto와 T의 역할을 비교해보면 auto가 "템플릿 타입 추론" 방식으로 결정된다는 게 별로 이상한 일이 아님을 알 수 있을 것이다. auto의 타입 추론 방식은 심지어 배열과 함수가 지정됐을 때 조차도 템플릿과 같은 방식으로 타입을 결정한다. 아래 예를 보자.
const char name[] = "R. N. Briggs"; // name의 타입은 const char[13]
auto arr1 = name; // arr1의 타입은 const char*
auto& arr2 = name; // arr2의 타입은 const char(&)[13]

void someFunc(int, double); // someFunc는 function
                            // 타입은 void(int, double) 
auto func1 = someFunc; // func1의 타입은 void (*)(int, double)
auto& func2 = someFunc; // func2의 타입은 void (&)(int, double)
 템플릿 타입 추론에서 비-참조형과 참조형의 인자에 함수나 배열을 넘겼을 때의 동작은 auto 타입 추론에서도 그대로 적용된다. 즉 비-참조형 인자에 함수나 배열을 넘기면 포인터형으로 바뀌어 추론된다. 이 정도면 "auto 타입 추론"은 "템플릿 타입 추론"과 같다고 할 만 하다.

 하지만 auto는 템플릿 타입 추론과 한 가지 다른 점이 있는데 바로 braced initializer가 초기화값으로 주어졌을 때이다. 아래 예를 보면 auto의 타입이 예상과 다르게 결정되는 걸 볼 수 있다.
int x1 = 27;
int x2(27);
int x3 = { 27 }; // C++11, uniform initialization
int x4{ 27 }; // C++11, uniform initialization

auto a1 = 27; // 타입은 int, 값은 27
auto a2(27);  // 타입은 int, 값은 27
auto a3 = { 27 }; // 타입은 std::initializer_list<int>, 값은 {27}
auto a4{ 27 };    // 타입은 std::initializer_list<int>, 값은 {27}
 C++11에서 소개된 uniform initialization 덕분에 위의 x1, x2, x3, x4가 모두 가능한 선언이다. 하지만 똑같은 방식으로 auto 선언문으로 바꾸면 결과는 달라진다. 주석에서 볼 수 있듯이 a3, a4는 int형이 아니고 std::initializer_list<int>형으로 추론되기 때문이다. braced initializer를 이미 사용해 본 적이 있다면 컴파일러가 상황에 맞춰서 그 값들을 std::initializer_list형으로 생성한다는 걸 이미 알고 있을 것이다. auto 선언문에서도 컴파일러는 braced initializer를 std::initializer_list형으로 바꿔서 타입 추론을 수행한다. 컴파일러의 그런 동작을 이미 알고 있었다면 어느 정도 이해가 되는 상황일 것이다.

 위와 같이 auto는 braced initializer를 std::initializer_list형으로 인식하고 타입 추론을 수행하지만 템플릿에 braced initializer를 넘기면 타입 추론을 하지 못하고 컴파일에 실패한다. 이 점이 두 타입 추론의 차이점이다. 즉 아래 예에서 f()의 호출은 실패한다.
// auto 타입 추론
auto x = { 11, 23, 9 }; // x의 타입은 std::initializer_list형으로 추론됨

// 템플릿 타입 추론
template
void f(T param);

f({ 11, 23, 9 }); // error! 타입 추론을 할 수 없음.

 C++14에서는 한 가지 더 짚고 가야할 것이 있다. C++14부터는 auto를 함수의 return 타입과 람다의 인자에서도 사용이 가능한데 이 때는 braced initializer를 std::initializer_list형으로 인식하지 않고 컴파일을 실패하게 된다. 즉 아래 두 가지 경우에 대해서는 (auto 타입 추론이 아닌) "템플릿 타입 추론"을 수행한다.
auto createInitList() {
    return { 1, 2, 3 }; // error: { 1, 2, 3 }을 타입 추론할 수 없음.
}
std::vector<int> v;
...

auto resetV = [&v](const auto& newValue) { v = newValue; }; // C++14
...

resetV({ 1, 2, 3 }); // error! { 1, 2, 3 }을 타입 추론할 수 없음.
 위의 두 예에서 모두 auto는 에러가 난다. "템플릿 타입 추론"과 똑같이 braced initializer를 통해서 타입 추론을 하지 못하는 것을 볼 수 있다. 이렇게 보면 auto가 타입 추론되는 방식은 변수를 선언할 때만 braced initializer를 인식(std::initializer_list)한다는 점을 제외하면 템플릿 타입 추론과 완전히 동일하다.

 마지막으로 간단히 요약해 보자면...
  1. auto 타입 추론은 템플릿 타입 추론과 거의 같지만 braced initializer를 std::initializer_list형으로 인식해서 추론을 수행한다는 점이 다르다. (템플릿 타입 추론은 braced initializer를 통해서는 타입 추론을 실패함)
  2. auto가 함수의 return 타입, 람다의 인자에 사용되었을 때는 템플릿 타입 추론과 완전히 동일하게 동작한다.(즉 braced initializer를 통한 타입 추론은 실패한다)

2017년 2월 21일 화요일

Template Type Deduction (템플릿 타입 추론)

 수많은 C++관련 도서가 있고 그 수 만큼 많은 저자들이 있지만 그 중에 내가 인상깊게 본 도서들을 꼽자면 Effective 시리즈를 꼽을 수 있겠다. 전부 다 본건 아니지만 그래도 Effective 시리즈 작가인 Scott Meyers의 책을 볼 기회가 생긴다면 기꺼이 시간을 내서 볼 용의가 있다.

 최근에 보게된 Effective Modern C++도 상당히 인상적이다. C++11부터 크게 발전해가는 C++을 제대로 사용해 보려고 여기 저기 인터넷을 뒤져가며 새로운 문법을 익히다보니 머리속이 복잡해지는 걸 느끼고 있던 참에 여러가지 복잡하게 얽힌 생각들을 대부분 깔끔하게 정리할 수 있도록 도와준 책이었다.

 그래서 정리된 것들을 내 머리속에 오래 유지할 수 있도록 몇 번에 걸쳐 포스팅을 해볼까 한다. 그 첫 번째로 C++이 수행하는 타입 추론(Type Deduction)에 관해서 정리해보자.

 C++11이 나오기 전에는 템플릿(Template)말고는 타입 추론이 수행되는 경우가 없었지만 C++11에 auto와 decltype이 추가되면서 두 가지가 더 늘었다. 조금 정확히 얘기하자면 타입 추론은 C++ 컴파일러가 개발자가 지정하지 않은 타입을 주변의 다른 코드를 이용해서 결정하는 작업이다. 따라서 컴파일러가 개발자의 의도대로 움직여주지 않으면 엉뚱한 결과를 초래하는 경우도 종종 발생한다. 사실 컴파일러가 오동작하는 건 아니고 개발자가 컴파일러의 규칙을 제대로 알지 못하기 때문에 생기는 문제이며 그 때문에 그 규칙을 여기에 정리하려고 하는 것이다.

 우선 이번 글에서는 템플릿의 타입 추론에 대해서 얘기해 보고자한다.

 일반적인 함수 템플릿은 아래와 같은 형식으로 선언될 것이다.
template<typename T>
void f(ParamType param);
 여기서 ParamType은 'const T&'같은 T를 이용하는 타입이 될 것이다.

 그리고 이 함수를 호출할 때는 아래와 같은 형태로 호출될 것이다.
f(expr);
 이 때 컴파일러는 expr의 타입에 기초하여 ParamType과 T의 타입추론을 수행한다.

ParamType이 다음의 3가지 경우에 대해서 다르게 동작하므로 개발자는 이 규칙을 정확히 이해하고 있어야 타입 추론이 엉뚱하게 되는 경우를 피할 수 있을 것이다.
  1. ParamType이 Universal 참조가 아닌 참조형(Reference)인 경우 (T&)
  2. ParamType이 Universal 참조형인 경우 (T&&)
  3. ParamType이 참조형이 아닌 경우 (T)
차례대로 하나 씩 살펴보자.

1. ParamType이 Universal 참조가 아닌 참조형(T& 또는 const T&)인 경우
 이 경우가 가장 간단한 경우로 책에서는
  1) expr의 타입에서 참조를 제거(있다면)하고
  2) 그 타입을 ParamType과 패턴 매칭시켜서 T의 타입을 결정(추론)
하면 된다.

 패턴 매칭이라는 용어를 썼지만 패턴 매칭이 어떻게 이루어지는 지에 대한 설명이 없다. 내가 아는 일반적인 패턴 매칭으로는 사실 아래 예제의 모든 경우를 만족시키지 않는다.  주석을 보면 무엇이 매칭되는 대상들인지 적어놓았으므로 자세히 보기 바란다.

template<typename T>
void f(T& param); // 여기서 param은 참조형

int x = 27;        // x의 타입은 int
const int cx = x;  // cx의 타입은 const int
const int& rx = x; // rx의 타입은 const int&

f(x);  // int와 T& 매칭,       T는 int,       param은 int&
f(cx); // const int와 T& 매칭, T는 const int, param은 const int&
f(rx); // const int와 T& 매칭, T는 const int, param은 const int&
template<typename T>
void f(const T& param);

int x = 27;        // x의 타입은 int
const int cx = x;  // cx의 타입은 const int
const int& rx = x; // rx의 타입은 const int&

f(x);  // int와 const T& 매칭,       T는 int, param은 const int&
f(cx); // const int와 const T& 매칭, T는 int, param은 const int&
f(rx); // const int와 const T& 매칭, T는 int, param은 const int&
 여기서 패턴 매칭이라 함은 참조 제거된 expr의 타입을 ParamType과 매칭 시키는 데 적절히 매칭된 부분을 제외하고 남아있는 타입을 T에 매칭하는 방식인가 보다.  두 번째 예제의 마지막 함수 호출 f(rx)를 예로 들면 아래와 같이 const는 매칭되므로 제거하고 남은 건 int이므로 T는 int가 된다. 즉 매칭된 건 제거하고 남은 것을 T에 매칭하면 되는 것이다.

참조 제거된 expr : const int
    ParamType : const T   &

 패턴 매칭이라는 용어때문에 좀 찜찜한 느낌을 지울 수 없다. 저자가 매칭 규칙을 정확히 설명을 해주었다면 좋았을 것 같은데 그렇지 않다. 누구나 알 수 있을 것이라 생각했는지 따로 설명을 하지 않고 있다.

 그리고 책에서는 포인터형에 대해서도 같은 방식으로 타입 추론이 일어난다고 되어있지만 추후에 인터넷에 공개된 도서의 정오표를 통해서 포인터형은 사실 뒤에 나올 3번의 경우에 속한다고 수정하였다. 내가 보기에도 맞는 말이다. 따라서 여기서는 언급하지 않도록 하겠다.

2. ParamType이 Universal 참조형(T&&)인 경우
 이 규칙은 C++11에 rvalue 참조형이 추가되면서 템플릿 타입 추론에도 영향을 미치게된 경우이다. 아래 예제와 같이 ParamType은 rvalue 참조형 같이 생겼지만 실제 전달되는 expr의 타입에 따라 다음과 같은 타입 추론이 수행된다.
  • expr의 타입이 lvalue이면 T와 ParamType은 lvalue 참조형으로 추론된다.
  • expr의 타입이 rvalue이면 1번과 같은 방식으로 추론된다.
template<typename T>
void f(T&& param); // 여기서 param은 Universal 참조형

int x = 27;        // x는 int
const int cx = x;  // cx는 const int
const int& rx = x; // rx는 const int&

f(x);  // x는 lvalue,  따라서 T는 int&,       param은 int&
f(cx); // cx는 lvalue, 따라서 T는 const int&, param은 const int&
f(rx); // cx는 lvalue, 따라서 T는 const int&, param은 const int&
f(27); // 27은 rvalue, 따라서 T는 int,        param은 int&&
 lvalue의 경우는 그냥 모두 lvalue 참조형으로 추론되므로 추가 설명이 필요 없을 것 같고 rvalue의 경우에는 1번 규칙처럼 상수 27의 타입 int와 T&&가 패턴매칭해서 T는 int가 되고 param은 int&&가 된 것이다.

3. ParamType이 참조형이 아닌 경우(T)
 이 경우에는 인자가 그냥 pass-by-value 방식으로 전달되는 것과 같다. 즉 param의 타입은 expr의 복사본의 타입이 되는 것이다. 다음 예제를 보도록 하자
template<typename T>
void f(T param); // 여기서 param은 비-참조형

int x = 27;        // x는 int
const int cx = x;  // cx는 const int
const int& rx = x; // rx는 const int&
const char* const ptr = "Fun with pointers"; // ptr은 const char* const

f(x);   // T와 param의 타입은 모두 int
f(cx);  // T와 param의 타입은 모두 int
f(rx);  // T와 param의 타입은 모두 int
f(ptr); // T와 param의 타입은 모두 const char*
 위 예제에서 "const int형인 cx를 넘겼는데 왜 T가 const int가 아니고 int일까?"라고 의문을 제기할 수 있다. 바로 전에 말한 것과 같이 ParamType이 참조형이 아닌 경우에는 값의 복사본이 전달되므로 지정된 expr의 타입이 참조형이던 const형이던 상관없이 그 복사본이 전달되고 그 복사본은 수정되지 못할 이유가 없으므로 원래 붙어있던 const성질은 사라지게 되는 것이다. 마찬가지로 volatile 같은 성질들도 모두 사라지게 된다.

 다만 ptr의 경우에 const가 그대로 있는 것이 이상해 보일 수 있지만 f(ptr)에서 복사되는 건 ptr그 자체이지 ptr이 가리키는 객체가 복사되는 게 아니기 때문에 그 객체의 const성질은 그대로 유지되어야 하고 ptr 자신의 const성질만 사라지는 게 맞다. 따라서 ptr이 가리키는 객체의 const인 왼쪽의 const는 그대로 남고 ptr자신의 const는 사라져야 하기 때문에 오른쪽의 const만 사라지게 되는 것이다.

 위의 3가지 경우 외에 생각해 보아야 할 예외 케이스가 2가지가 있다. 바로 함수 인자로 배열이 전달될 때와 함수가 전달되는 경우이다. 이런 경우를 실제 앞으로 만나게 될지는 모르겠지만 정리하는 김에 같이 해보도록 하겠다.

* 배열을 인자로 넘겼을 때
 배열을 템플릿 함수에 인자로 넘기면 타입 추론은 어떻게 될까? 아래 예제를 보자.
template<typename T>
void f(T param); // 여기서 param은 비-참조형

const char arr[] = "Array";

f(arr); // arr은 const char[6]이지만 T는 const char*
 ParamType이 비-참조형일 때는 const char[6]을 const char*로 바꿔서 타입 추론을 수행한다. 하지만 아래 예제의 경우는 좀 다르다.
template<typename T>
void f(T& param); // 여기서 param은 참조형

const char arr[] = "Array";

f(arr); // arr은 const char[6], T는 const char[6], param은 const char(&)[6]
 ParamType이 참조형일 때는 배열을 포인터로 바꾸지 않고 배열 그대로 받아들인다.

* 함수를 인자로 넘겼을 때
 크게 쓸 일은 없지만 함수를 템플릿 함수에 인자로 넘겼을 때도 배열과 비슷한 방식의 동작이 수행된다는 것도 알아두자.
void someFunc(int, double); // 함수의 타입은 void(int, double)

template<typename T>
void f1(T param); // 여기서 param은 비-참조형

template<typename T>
void f2(T& param); // 여기서 param은 참조형

f1(someFunc); // T는 void(*)(int, double, param은 void(*)(int, double)
f2(someFunc); // T는 void(int, double),   param은 void(&)(int, double)
 ParamType이 참조형일 때만 배열에서 처럼 함수타입이 유지되고 비-참조형인 경우에는 함수 포인터로 바꿔서 타입 추론을 수행한다.