다음과 같은 함수가 있다고 생각해 봅시다.
int add(int x, int y)
{
return x + y;
}
이 간단한 함수는 int 정수 두 개를 더해서 정수 결과를 반환합니다.
하지만 만약 소수점이 있는 실수 float 두 개를 더하는 기능이 필요하다면 어떻게 해야 할까요?
위에서 만든 add() 함수는 실수를 다루기에 적합하지 않습니다.
실수를 매개변수로 넣더라도 정수로 강제 변환되면서 소수점 아래 값들이 사라져 버리기 때문입니다.
이 문제를 해결하는 한 가지 방법은, 아래처럼 함수 이름을 조금씩 다르게 지어서 여러 개를 만드는 것입니다.
int addInteger(int x, int y)
{
return x + y;
}
double addDouble(double x, double y)
{
return x + y;
}
하지만 이 방식은 불편한 점이 있습니다.
게다가 “정수 2개가 아니라 정수 3개를 더하는 함수도 필요해!” 같은 일이 생기면,
각각에 대해 또 다른 이름을 만들어야 해서 함수 이름 관리가 금방 부담스러워집니다.
다행히 C++에는 이런 상황을 깔끔하게 해결하는 기능이 있습니다. 바로 함수 오버로딩입니다.
함수 오버로딩이란 이름은 같지만 매개변수의 타입이 서로 다르기만 하다면 똑같은 이름을 가진 함수를 여러개 만들수 있게 해주는 기능입니다.
add()를 오버로딩해 보기정수용 add()가 이미 있으니, 실수용 add()를 하나 더 만들면 됩니다.
double add(double x, double y){
return x + y;
}
이제 같은 범위에 add()가 두 개 생겼습니다.
int add(int x, int y) // 정수 버전
{
return x + y;
}
double add(double x, double y) // 실수(부동소수점) 버전
{
return x + y;
}
int main(){
return 0;
}
이 프로그램은 문제없이 컴파일됩니다. 왜냐하면 두 함수는 매개변수 타입이 다르기 때문에, 컴파일러가 서로 다른 함수로 구분할 수 있습니다.
즉, 이름은 같지만 별개의 함수로 취급됩니다.
함수가 오버로딩되어 있을 때, add(...) 처럼 호출하면 컴파일러는 이렇게 행동합니다.
add()를 찾아서 선택합니다.이 과정을 오버로드 결정이라고 합니다.
#include <iostream>
int add(int x, int y){
return x + y;
}
double add(double x, double y){
return x + y;
}
int main(){
std::cout << add(1, 2); // add(int, int)를 호출
std::cout << '\n';
std::cout << add(1.2, 3.4); // add(double, double)를 호출
return 0;
}
add(1, 2) 처럼 정수를 넣으면 컴파일러는 add(int, int)를 선택합니다.add(1.2, 3.4) 처럼 실수를 넣으면 컴파일러는 add(double, double) 를 선택합니다.함수 오버로딩은 프로그램을 더 단순하게 만들어 줍니다.
즉, 외워야 할 함수 이름의 개수를 줄여서 코드 관리가 쉬워집니다.
그래서 오버로딩은 적극적으로 사용해도 좋고, 실제로 자주 사용됩니다.
11.2 챕터에서는 컴파일러가 이름이 같은 이 함수들을 구체적으로 어떻게 구별하는지 자세히 알아보겠습니다.
만약 오버로딩된 함수들을 제대로 구별되게 만들지 않으면, 컴파일러는 에러를 일으킵니다.
아래 항목들이 함수 오버로드 구분에 사용됩니다.
| 함수 속성 | 구분에 사용됨? | 참고 |
|---|---|---|
| 매개변수 개수 | 예 | |
| 매개변수 타입 | 예 | typedef, 타입 별칭(type alias), 값 전달 매개변수의 const는 구분에 포함되지 않음. ...(ellipsis)는 포함됨 |
| 반환 타입 | 아니오 |
함수의 반환 타입(return type)은 오버로드 구분에 사용되지 않습니다.
이 부분은 뒤에서 다시 설명하겠습니다.
오버로드된 함수들은 각 함수의 매개변수 개수가 다르면 서로 구분됩니다. 예를 들어
int add(int x, int y)
{
return x + y;
}
int add(int x, int y, int z)
{
return x + y + z;
}
컴파일러는 다음을 쉽게 구분할 수 있습니다.
add(int, int)add(int, int, int)각 오버로드 함수의 매개변수 타입 목록이 서로 다르면 함수도 구분할 수 있습니다.
예를 들어, 아래 오버로드들은 모두 서로 구분됩니다.
int add(int x, int y); // 정수 버전
double add(double x, double y); // 실수 버전
double add(int x, double y); // 혼합 버전
double add(double x, int y); // 혼합 버전
타입 별칭(Type aliases)이나 typedef는 새로운 타입을 만드는 것이 아니라 기존 타입에 다른 이름만 붙여주는 것입니다.
따라서 이를 사용한 오버로딩은 구별되지 않습니다
typedef int Height; // typedef
using Age = int; // 타입 별칭 (type alias)
void print(int value);
void print(Age value); // print(int)와 구별되지 않음 (에러)
void print(Height value); // print(int)와 구별되지 않음 (에러)
또한, 값으로 전달(pass by value)받는 매개변수의 경우, const가 붙어 있어도 구별되지 않습니다.
void print(int);
void print(const int); // print(int)와 구별되지 않음 (에러)
함수를 오버로드할 때, 반환 타입은 구분 기준이 아닙니다.
예를 들어, 랜덤 값을 반환하는 함수를 만들고 싶은데
하나는 int를 반환하고, 다른 하나는 double을 반환하게 만들고 싶다고 해봅시다.
int getRandomValue();
double getRandomValue();
하지만 Visual Studio 2019 에서는 다음과 같은 컴파일 오류가 발생합니다
error C2556: 'double getRandomValue(void)': overloaded function differs only by return type from 'int getRandomValue(void)'
이건 자연스러운 결과입니다.
컴파일러 입장에서 아래 코드를 보면
getRandomValue();
두 함수 중 어느 것을 호출해야 하는지 알 수 없기 때문입니다.
함수의 타입 시그니처(보통 그냥 '시그니처'라고 부름)는 함수들을 서로 구별하는 데 사용되는 정보들을 말합니다.
C++에서 시그니처는 다음 요소들을 포함합니다.
const 등)다시 강조하지만, 반환 타입은 시그니처에 포함되지 않습니다.
컴파일러가 코드를 컴파일할 때 네임 맹글링이라는 과정을 거칩니다.
쉽게 말해, 링커가 함수들을 헷갈리지 않도록 컴파일러가 함수 이름을 내부적으로 복잡하게 바꿔버리는 것입니다.
이때 매개변수의 개수나 타입을 이름에 섞어 넣습니다.
예를 들어, 소스 코드에는 int fcn()이라는 함수가 있지만, 컴파일된 코드에서는 __fcn_v 같은 이름으로 바뀔 수 있습니다.
반면 int fcn(int)는 __fcn_i처럼 바뀔 수 있죠.
덕분에 소스 코드에서는 둘 다 fcn()이라는 같은 이름을 쓰지만, 컴파일러 내부에서는 __fcn_v와 __fcn_i라는 서로 다른 이름으로 관리되어 충돌이 나지 않는 것입니다.
잘 구분된 오버로딩 함수들을 만들어 두었다고 해서 끝이 아닙니다.
함수가 호출될 때, 컴파일러는 그 호출에 딱 맞는 함수 선언을 무사히 찾아낼 수 있어야 합니다.
오버로딩 되지 않은 일반 함수(이름이 고유한 함수)의 경우, 호출 시 매칭될 가능성이 있는 함수는 단 하나뿐입니다.
조건이 맞거나(혹은 형 변환을 거쳐 맞추거나), 아니면 아예 맞지 않아서 컴파일 에러가 나거나 둘 중 하나입니다.
반면 오버로딩된 함수들의 경우, 하나의 함수 호출에 매칭될 가능성이 있는 함수가 여러 개 존재할 수 있습니다.
함수 호출은 단 하나의 함수로만 연결되어야 하므로, 컴파일러는 여러 함수 중 어떤 것이 가장 '최적의 매칭'인지 결정해야 합니다.
이렇게 특정 오버로딩 함수와 함수 호출을 짝지어주는 과정을 오버로딩 해석(Overload resolution)이라고 부릅니다.
함수 호출 시 전달한 인자의 타입과 함수의 매개변수 타입이 정확히 일치하는 간단한 경우에는 이 과정이 아주 직관적입니다.
#include <iostream>
void print(int x)
{
std::cout << x << '\n';
}
void print(double d)
{
std::cout << d << '\n';
}
int main()
{
print(5); // 5는 int형이므로, print(int)와 매칭됩니다.
print(6.7); // 6.7은 double형이므로, print(double)과 매칭됩니다.
return 0;
}
그렇다면, 함수 호출 시 전달한 인자의 타입이 오버로딩된 함수들의 매개변수 타입 중 그 어떤 것과도 정확히 일치하지 않는다면 어떻게 될까요? 예를 들면 다음과 같습니다.
#include <iostream>
void print(int x)
{
std::cout << x << '\n';
}
void print(double d)
{
std::cout << d << '\n';
}
int main()
{
print('a'); // char형은 int나 double과 정확히 일치하지 않는데, 어떻게 될까요?
print(5L); // long형은 int나 double과 정확히 일치하지 않는데, 어떻게 될까요?
return 0;
}
정확히 일치하는 함수가 없다고 해서 반드시 오류가 발생하는 것은 아닙니다.
왜냐하면
char → int 또는 double 로 변환 가능long → int 또는 double 로 변환 가능하지만 중요한 질문은 이것입니다. 어떤 변환이 가장 적절한 변환일까?
이 강의에서는 컴파일러가 주어진 함수 호출을 특정 오버로딩 함수와 어떻게 짝지어주는지 알아보겠습니다.
오버로딩된 함수가 호출되면, 컴파일러는 어떤 함수가 가장 최적의 매칭인지 알아내기 위해 일련의 규칙을 순서대로 밟아 나갑니다.
각 단계마다 컴파일러는 함수 호출에 사용된 인자에 다양한 형 변환을 적용해 봅니다.
변환을 적용할 때마다 일치하는 오버로딩 함수가 있는지 확인합니다.
확인을 마치면 그 단계가 끝나며, 결과는 다음 세 가지 중 하나가 됩니다.
만약 컴파일러가 모든 단계를 끝까지 거쳤는데도 일치하는 함수를 찾지 못하면,
일치하는 오버로딩 함수를 찾을 수 없다는 컴파일 에러를 발생시킵니다.
Step 1) 정확한 일치 찾기
먼저 컴파일러는 정확히 일치하는 함수가 있는지 찾습니다.void foo(int){} void foo(double){} int main() { foo(0); // foo(int)와 정확히 일치 foo(3.4); // foo(double)와 정확히 일치 return 0; }
0은int타입이므로foo(int)가 선택됩니다.
Step 1-2) 사소한 변환 적용
컴파일러는 값은 그대로 유지하면서 타입만 약간 변경하는 변환도 시도합니다.
int→const intdouble→const double&non-reference→referencevoid foo(const int){} void foo(const double&) // double에 대한 참조 {} int main() { int x { 1 }; foo(x); // int → const int 로 변환됨 double d { 2.3 }; foo(d); // double → const double& 로 변환됨 return 0; }이러한 변환도 정확한 일치로 취급됩니다.
Step 2) 숫자 승격
정확한 일치가 없다면, 컴파일러는 숫자 승격을 시도합니다.void foo(int){} void foo(double){} int main() { foo('a'); // char → int 승격 foo(true); // bool → int 승격 foo(4.5f); // float → double 승격 return 0; }즉,
char→intbool→intfloat→double이러한 승격은 자동으로 발생합니다.
Step 3) 숫자 변환
승격으로 해결되지 않으면 숫자 변환을 시도합니다.#include <string> void foo(double){} void foo(std::string){} int main() { foo('a'); // char → double 변환됨 return 0; }
char→int→double이런 방식으로 변환됩니다.중요한 핵심
숫자 승격은 숫자 변환보다 우선순위가 높습니다.Step 4) 사용자 정의 변환
클래스는 사용자 정의 변환을 만들 수 있습니다.
// 아직 클래스를 배우지 않았으므로 이해되지 않아도 걱정하지 마세요 class X // X라는 새로운 타입을 정의합니다 { public: operator int() { return 0; } // X 타입에서 int 타입으로의 사용자 정의 변환입니다 }; void foo(int) { } void foo(double) { } int main() { X x; // 여기서 X 타입의 객체(이름은 x)를 생성합니다 foo(x); // x는 X에서 int로의 사용자 정의 변환을 사용해 int 타입으로 변환됩니다 return 0; }Step 5) 사용자 정의 변환으로도 찾지 못하면, 줄임표(ellipsis, ...)를 사용하는 매칭 함수를 찾습니다.
Step 6) 이 시점까지도 일치하는 함수를 찾지 못했다면, 컴파일러는 포기하고 일치하는 함수를 찾을 수 없다는 컴파일 에러를 발생시킵니다.
같은 확인 단계(step) 내에서 매칭될 수 있는 함수를 컴파일러가 두 개 이상 발견했을 때 발생합니다.
이때 컴파일러는 에러를 띄우고 실행을 멈춥니다.
void foo(int)
{
}
void foo(double)
{
}
int main()
{
foo(5L); // 5L은 long 타입입니다
return 0;
}
리터럴 5L은 long 타입입니다. 1단계 정확한 일치 와 2단계 숫자 승격 에서는 매칭을 찾지 못합니다.
그다음 3단계 숫자 변환 를 시도할 때, long 인자는 int로 변환될 수도 있고 double로 변환될 수도 있습니다.
두 가지 가능한 매칭이 같은 단계에서 발견되었으므로, 이 함수 호출은 모호해집니다.
여러 개의 매칭 항목이 발견되면 모호한 함수 호출 에러가 발생합니다.
즉, 같은 단계 안에서는 어떤 매칭이 다른 매칭보다 더 낫다고 우열을 가리지 않습니다.
다음도 모호한 매칭의 예입니다.
void foo(unsigned int)
{
}
void foo(float)
{
}
int main()
{
foo(0); // int는 unsigned int나 float로 숫자 변환될 수 있습니다
foo(3.14159); // double은 unsigned int나 float로 숫자 변환될 수 있습니다
return 0;
}
0은 foo(unsigned int)로, 3.14159는 foo(float)로 매칭될 것이라 예상할 수 있지만, 둘 다 모호한 매칭 에러를 냅니다.
int와 double 모두 unsigned int나 float 어느 쪽으로든 변환이 가능하며(숫자 변환), 둘의 우선순위가 동등하기 때문입니다.
1. 정확한 함수 추가
void foo(int){}
2. 명시적 캐스팅
int x{ 0 };
foo(static_cast<unsigned int>(x)); // unsigned int 버전 호출
3. 리터럴 접미사 사용
foo(0u); // unsigned int literal
컴파일러는 각 인자를 모두 비교합니다.
#include <iostream>
void print(char, int)
{
std::cout << 'a' << '\n';
}
void print(char, double)
{
std::cout << 'b' << '\n';
}
void print(char, float)
{
std::cout << 'c' << '\n';
}
int main()
{
print('x', 'a');
return 0;
}
char → Step 1) 정확히 일치Step 2) 승격 char → intStep 3) 변환 char → doubleStep 3) 변환 char → float숫자 승격이 숫자 변환보다 우선되므로 print(char, int)가 선택됩니다.때로는 특정 타입의 값을 넣어 함수를 호출했을 때, 우리가 원하지 않는 방식으로 작동하는 경우가 있습니다.
다음 예제를 함께 살펴볼까요?
#include <iostream>
void printInt(int x)
{
std::cout << x << '\n';
}
int main()
{
printInt(5); // 정상: 5를 출력합니다.
printInt('a'); // 97을 출력합니다. -- 우리가 의도한 결과일까요?
printInt(true); // 1을 출력합니다. -- 우리가 의도한 결과일까요?
return 0;
}
출력:
5
97
1
printInt(5) 는 숫자를 전달했으니 확실히 문제가 없지만, 나머지 두 번의 호출은 조금 의아합니다.
printInt('a')의 경우, 컴파일러는 함수 규칙에 맞추기 위해 문자 'a'를 정수 97로 몰래 변환(승격)해 버립니다.
true 역시 정수 1로 변환하죠. 게다가 컴파일러는 이런 행동을 하면서 아무런 경고나 에러도 띄우지 않습니다.
만약 우리가 char 나 bool 타입의 값으로 printInt() 를 호출하는 것을 아예 막고 싶다면 어떻게 해야 할까요?
= delete 지정자를 사용해 함수 삭제하기특정 함수가 아예 호출되지 못하도록 강력하게 막고 싶을 때, = delete 라는 문법을 사용하여 해당 함수를 삭제 처리할 수 있습니다.
만약 컴파일러가 코드를 읽다가 이 '삭제된 함수'를 사용하려는 시도를 발견하면, 컴파일 단계에서 오류가 발생하고 컴파일이 중단됩니다.
#include <iostream>
void printInt(int x)
{
std::cout << x << '\n';
}
void printInt(char) = delete; // 이 함수를 호출하면 컴파일이 중단(에러 발생)됩니다.
void printInt(bool) = delete; // 이 함수를 호출하면 컴파일이 중단(에러 발생)됩니다.
int main()
{
printInt(97); // 정상
printInt('a'); // 컴파일 에러: 삭제된 함수입니다.
printInt(true); // 컴파일 에러: 삭제된 함수입니다.
printInt(5.0); // 컴파일 에러: 모호한 매칭(ambiguous match)입니다.
return 0;
}
위 코드의 결과를 하나씩 살펴봅시다.
printInt('a') 는 방금 우리가 삭제한 printInt(char) 와 정확히 일치합니다. 따라서 컴파일러는 에러를 뿜어냅니다.printInt(true) 역시 삭제된 printInt(bool)과 정확히 일치하므로 컴파일 에러가 발생합니다.그런데 printInt(5.0)은 꽤 흥미로우며, 예상치 못한 결과를 보여줍니다.
이때 중요한 점은 삭제된 함수도 함수 선택 과정에 포함된다는 것입니다.
즉, 컴파일러는 다음 후보들을 모두 고려합니다.
printInt(int)printInt(char) (삭제됨)printInt(bool) (삭제됨)5.0 을 int char bool 중 어느 것으로 변환하는 것이 가장 완벽한지 명확하지 않기 때문에,
컴파일러는 헷갈린다는 의미로 "모호한 매칭(ambiguous match)"이라며 컴파일 에러를 발생시킵니다.
핵심 개념
= delete의 의미는
"이 함수는 존재하지 않는다" 가 아니라
"이 함수는 호출하는 것을 금지한다" 입니다.
즉,
- 삭제된 함수도 여전히 존재합니다.
- 그리고 인자 매칭 순서에 참여합니다.
- 하지만 선택되면 컴파일 오류가 발생합니다.
기본 인수란 함수 매개변수에 미리 정해둔 기본값을 말합니다. 다음 예시를 볼까요?
void print(int x, int y=10) // 10이 기본 인수입니다.
{
std::cout << "x: " << x << '\n';
std::cout << "y: " << y << '\n';
}
함수를 호출할 때, 기본 인수가 지정된 매개변수라면 우리가 값을 넘겨줄지 말지 선택할 수 있습니다.
우리가 값을 직접 넘겨주면 그 값이 사용되고, 값을 넘겨주지 않고 생략하면 미리 정해둔 '기본 인수' 값이 대신 사용됩니다.
함수에 적절한 기본값이 필요하면서도, 원한다면 언제든지 사용자가 다른 값으로 바꿀 수 있게 해주고 싶을 때 아주 좋은 선택입니다.
예를 들어, 다음은 기본 인수가 흔히 쓰일 만한 함수들입니다. (주사위를 굴리거나 로그 파일을 여는 함수)
int rollDie(int sides=6);
void openLogFile(std::string filename="default.log");
또한, 이미 만들어진 함수에 새로운 매개변수를 추가해야 할 때도 유용합니다.
기본 인수 없이 매개변수만 달랑 추가하면, 예전에 이 함수를 쓰던 모든 코드들이(새 매개변수를 안 넘겨주니까) 에러가 나면서 고장 나 버립니다. 결국 기존 코드를 일일이 고쳐야 하죠. 하지만 새 매개변수에 기본 인수를 달아주면, 예전 코드들은 알아서 기본값을 쓰기 때문에 아무 문제 없이 작동하고, 새로운 코드에서는 원하는 값을 명시해서 넣을 수 있으니 무척 편리합니다.
한 번 선언한 기본 인수는 같은 번역 단위안에서 다시 선언할 수 없습니다.
즉, 전방 선언과 함수 정의가 둘 다 있을 때,
#include <iostream>
void print(int x, int y=4); // 전방 선언
void print(int x, int y=4) // 컴파일 에러: 기본 인수 재정의
{
std::cout << "x: " << x << '\n';
std::cout << "y: " << y << '\n';
}
또한 기본 인수는 사용하기 전에 먼저 선언되어 있어야 합니다.
#include <iostream>
void print(int x, int y); // 전방 선언(기본 인수 없음)
int main()
{
print(3); // 컴파일 에러: y의 기본 인수가 아직 정의되지 않음
return 0;
}
void print(int x, int y=4)
{
std::cout << "x: " << x << '\n';
std::cout << "y: " << y << '\n';
}
// foo.h
#ifndef FOO_H
#define FOO_H
void print(int x, int y=4);
#endif
// main.cpp
#include "foo.h"
#include <iostream>
void print(int x, int y)
{
std::cout << "x: " << x << '\n';
std::cout << "y: " << y << '\n';
}
int main()
{
print(5);
return 0;
}
기본 인수가 있는 함수도 오버로딩(이름이 같은 함수를 매개변수만 다르게 여러 개 만드는 것)할 수 있습니다.
예를 들어 다음 코드는 정상적으로 작동합니다.
#include <iostream>
#include <string_view>
void print(std::string_view s)
{
std::cout << s << '\n';
}
void print(char c = ' ')
{
std::cout << c << '\n';
}
int main()
{
print("Hello, world"); // print(std::string_view)를 호출함
print('a'); // print(char)를 호출함
print(); // print(char)를 호출함
return 0;
}
세 번째 print() 호출은 사실 print(char)를 부른 것입니다.
우리가 명시적으로 빈칸인 print(' ')를 넣어서 호출한 것과 똑같이 작동하죠.
이제 다음 경우를 살펴봅시다.
void print(int x); // 시그니처: print(int)
void print(int x, int y = 10); // 시그니처: print(int, int)
void print(int x, double y = 20.5); // 시그니처: print(int, double)
기본값은 함수의 '시그니처(함수를 구분 짓는 특징)'에 포함되지 않습니다.
따라서 위 함수들은 서로 다른 매개변수 타입을 가지고 있으므로, 오버로딩된 별개의 함수들로 잘 구분됩니다.
기본 인수 때문에 함수 호출이 쉽게 애매해질 수 있습니다.
void foo(int x = 0) {}
void foo(double d = 0.0) {}
int main()
{
foo(); // 모호함(어느 foo를 호출해야 할지 결정 불가)
return 0;
}
이 경우 컴파일러는 foo()가 foo(0)인지 foo(0.0)인지 판단할 수 없습니다.
두 숫자의 최댓값을 구하는 함수를 작성한다고 가정해 봅시다. 아마 이렇게 작성할 수 있을 거예요.
int max(int x, int y) {
return (x < y) ? y : x;
// 참고: std::max 함수가 < 연산자를 사용하기 때문에 여기서도 > 대신 < 를 사용합니다.
}
이 함수를 호출할 때 다른 값을 넘겨줄 수는 있지만, 매개변수의 타입은 고정되어 있습니다.
즉, 이 함수에는 int 값만 넣을 수 있죠. 결국 이 함수는 정수(또는 정수로 자동 변환될 수 있는 타입)에만 제대로 작동합니다.
그렇다면 나중에 double 타입의 실수 두 개를 비교해서 최댓값을 찾고 싶어지면 어떻게 해야 할까요?
C++에서는 함수의 모든 매개변수 타입을 명시해야 하므로, 당장 할 수 있는 해결책은 double 타입을 사용하는 새로운 max 함수를 오버로딩하여 만드는 것뿐입니다.
double max(double x, double y) {
return (x < y) ? y : x;
}
우리가 지원하고 싶은 매개변수 타입이 늘어날 때마다 내용이 똑같은 오버로딩 함수를 매번 만드는 것은 유지보수를 힘들게 하고, 실수를 유발하며, 프로그래밍의 핵심 원칙인 DRY 원칙을 명백히 위반하는 일입니다.
결국 지금 우리에게 절실히 필요한 것은, 어떤 타입의 인수를 넣든 모두 처리할 수 있는 단 하나의 max 버전을 작성하는 방법입니다.
C++에서 템플릿 시스템은 다양한 데이터 타입과 함께 작동할 수 있는 함수나 클래스를 만드는 과정을 훨씬 단순하게 만들고자 설계되었습니다.
내용이 거의 똑같은 여러 개의 함수나 클래스를 일일이 수동으로 만드는 대신, 우리는 단 하나의 템플릿만 만들면 됩니다.
일반적인 함수 정의처럼, 템플릿 정의도 함수나 클래스가 어떻게 생겼는지 형태를 설명해 줍니다.
하지만 모든 타입을 정확히 명시해야 하는 일반 정의와 달리, 템플릿에서는 하나 이상의 자리 표시자 타입을 사용할 수 있습니다.
자리 표시자 타입이란 템플릿을 정의할 당시에는 정확히 어떤 타입인지 알 수 없지만, 나중에(템플릿을 실제로 사용할 때) 제공될 임의의 타입을 대신하는 역할을 합니다.
템플릿이 한 번 정의되고 나면, 컴파일러는 이 템플릿을 사용해 필요한 만큼 오버로딩된 함수(또는 클래스)를 알아서 척척 만들어 냅니다.
이때 각각의 함수는 서로 다른 실제 타입을 사용하게 되죠!
결과적으로는 우리가 직접 만들었을 때와 똑같이 타입별로 거의 똑같이 생긴 여러 개의 함수나 클래스가 생겨납니다.
하지만 우리는 단 하나의 템플릿만 만들고 관리하면 되며, 나머지를 만드는 번거로운 작업은 컴파일러가 대신 해줍니다.
함수 템플릿은 각기 다른 실제 타입을 사용하는 하나 이상의 오버로딩된 함수를 생성하는 데 사용되는 함수 모양의 정의입니다.
다른 함수들을 만들어내는 데 사용되는 초기 함수 템플릿을 기본 템플릿 이라 부르고,
이 기본 템플릿으로부터 만들어진 함수들을 인스턴스화된 함수라고 부릅니다.
기본 함수 템플릿을 만들 때, 우리는 나중에 템플릿 사용자가 지정하게 될 매개변수 타입, 반환(return) 타입, 또는 함수 내부에서 쓰이는 타입 자리에 자리 표시자 타입을 사용합니다. (기술적으로는 타입 템플릿 매개변수(type template parameter) 라 부르고, 비공식적으로는 템플릿 타입이라고 부릅니다.)
함수 템플릿은 예제로 배우는 것이 최고입니다. 위에서 보았던 일반적인 max(int, int) 함수를 함수 템플릿으로 변환해 보겠습니다. 놀라울 정도로 쉬우니 그 과정을 차근차근 설명해 드릴게요.
int max(int x, int y) {
return (x < y) ? y : x;
}
이 함수에서는 int 타입을 세 번 사용했습니다. 매개변수 x에 한 번, 매개변수 y에 한 번, 그리고 함수의 반환 타입에 한 번입니다.
max() 함수 템플릿을 만들기 위해 우리는 두 가지를 할 것입니다.
첫 번째로, 나중에 지정되기를 원하는 실제 타입들을 타입 템플릿 매개변수로 바꿀 것입니다.
이 경우 우리가 바꿔야 할 타입은 int 하나뿐이므로, 타입 템플릿 매개변수도 하나만 있으면 됩니다.
이 매개변수의 이름은 T라고 하겠습니다.
아래는 모든 int 타입을 타입 템플릿 매개변수 T로 바꾼, 단일 템플릿 타입을 사용하는 새로운 함수입니다.
T max(T x, T y) // T가 무엇인지 정의하지 않았기 때문에 컴파일되지 않습니다.
{
return (x < y) ? y : x;
}
아주 좋은 출발입니다.
하지만 컴파일러는 T가 무엇인지 모르기 때문에 이 코드는 컴파일되지 않습니다!
게다가 이건 여전히 일반 함수일 뿐, 아직 함수 템플릿이 아닙니다.
두 번째로, 우리는 컴파일러에게 이것은 템플릿이고, T 는 어떤 타입이든 대신할 수 있는 자리 표시자 역할을 하는 타입 템플릿 매개변수라는 것을 알려줄 것입니다.
이 두 가지는 나중에 사용될 템플릿 매개변수들을 정의하는 템플릿 매개변수 선언(template parameter declaration)을 통해 이루어집니다.
템플릿 매개변수 선언의 범위는 바로 뒤따라오는 함수 템플릿(또는 클래스 템플릿)에만 엄격하게 제한됩니다.
따라서 각각의 함수 템플릿이나 클래스 템플릿은 자신만의 템플릿 매개변수 선언을 가져야 합니다.
template <typename T> // 이것이 T를 타입 템플릿 매개변수로 정의하는 템플릿 매개변수 선언입니다.
T max(T x, T y) // 이것은 max<T>를 위한 함수 템플릿 정의입니다.
{
return (x < y) ? y : x;
}
템플릿 매개변수 선언에서는 가장 먼저 template 키워드를 사용하여 우리가 템플릿을 만들고 있다는 것을 컴파일러에게 알립니다.
다음으로 꺾쇠괄호 < > 안에 우리 템플릿이 사용할 모든 템플릿 매개변수를 지정합니다.
각 타입 템플릿 매개변수에는 typename(권장) 또는 class 키워드를 쓰고, 그 뒤에 매개변수의 이름(예: T)을 적어줍니다.
믿기지 않으시겠지만, 벌써 다 끝났습니다!
우리는 다양한 타입의 인수를 받아들일 수 있는 템플릿 버전의 max() 함수를 성공적으로 만들었습니다.
함수 템플릿은 사실 진짜 함수가 아닙니다. 템플릿 코드 자체는 직접 컴파일되거나 실행되지 않죠.
대신 함수 템플릿은 한 가지 중요한 역할을 합니다. 바로 컴파일되고 실행될 '진짜 함수'를 붕어빵 찍어내듯 만들어내는 것입니다.
우리가 만든 max<T> 함수 템플릿을 사용하려면, 다음과 같은 문법으로 함수를 호출하면 됩니다.
max<actual_type>(arg1, arg2); // actual_type은 int나 double 같은 실제 타입을 의미합니다.
일반적인 함수 호출과 아주 비슷하게 생겼죠? 가장 큰 차이점은 꺾쇠괄호 < > 안에 타입을 추가한다는 것입니다.
이를 템플릿 인수라고 부르며, 템플릿 타입 T 대신 사용될 실제 타입을 지정하는 역할을 합니다.
#include <iostream>
template <typename T>
T max(T x, T y)
{
return (x < y) ? y : x;
}
int main()
{
std::cout << max<int>(1, 2) << '\n'; // max<int>(int, int) 함수를 인스턴스화(생성)하고 호출합니다.
return 0;
}
컴파일러가 max<int>(1, 2)라는 함수 호출을 보게 되면, 현재 max<int>(int, int)로 정의된 함수가 없다는 것을 파악합니다.
따라서 컴파일러는 우리가 만들어둔 max<T> 함수 템플릿을 사용하여 알아서 이 함수를 만들어냅니다.
이렇게 함수 템플릿을 기반으로 특정 타입을 가진 실제 함수를 만들어내는 과정을 함수 템플릿 인스턴스화, 줄여서 인스턴스화라고 부릅니다.
함수 호출로 인해 이렇게 자동으로 인스턴스화가 일어나는 것을 암시적 인스턴스화라고 합니다.
기술적으로 템플릿에서 만들어진 함수를 특수화라고 부르지만, 흔히 함수 인스턴스라고도 많이 부릅니다.
그리고 바탕이 되는 원본 템플릿은 기본 템플릿이라고 합니다.
이렇게 만들어진 함수 인스턴스는 모든 면에서 일반 함수와 똑같이 작동합니다.
함수가 만들어지는 과정은 단순합니다.
컴파일러가 기본 템플릿을 복사한 다음, 템플릿 타입(T)을 우리가 지정한 실제 타입(int)으로 싹 바꿔주는 것입니다.
그래서 우리가 max<int>(1, 2)를 호출할 때 생성되는 함수는 대략 아래와 같은 모습이 됩니다.
template<> // 이 부분은 일단 무시하세요
int max<int>(int x, int y) // 생성된 max<int>(int, int) 함수
{
return (x < y) ? y : x;
}
위의 예제 코드가 모든 인스턴스화 과정을 거친 후, 컴파일러가 실제로 컴파일하게 되는 최종 모습은 다음과 같습니다.
#include <iostream>
// 우리 함수 템플릿에 대한 선언입니다 (이제 정의 부분은 필요 없습니다)
template <typename T>
T max(T x, T y);
template<>
int max<int>(int x, int y) // 템플릿을 바탕으로 생성된 max<int>(int, int) 함수
{
return (x < y) ? y : x;
}
int main()
{
std::cout << max<int>(1, 2) << '\n'; // max<int>(int, int) 함수를 인스턴스화(생성)하고 호출합니다.
return 0;
}
중요한 점은, 함수 템플릿은 각 파일(번역 단위)에서 해당 타입으로 처음 호출될 때 단 한 번만 생성(인스턴스화)된다는 것입니다.
그 이후에 같은 타입으로 다시 호출하면, 새로 만들지 않고 이미 만들어둔 함수를 재사용합니다.
반대로, 함수 템플릿을 코드에 써놓기만 하고 한 번도 호출하지 않으면 함수는 아예 만들어지지 않습니다.
std::cout << max<int>(1, 2) << '\n'; // max<int>를 호출하겠다고 명시적으로 지정
위 호출에서는 T를 int로 바꾸겠다고 꺾쇠괄호로 명시했지만, 동시에 괄호 안에도 진짜int 값1, 2을 전달하고 있습니다.
이처럼 전달하는 인수의 타입과 만들고자 하는 함수의 타입이 같을 때는 굳이 <int>처럼 타입을 명시할 필요가 없습니다.
대신 템플릿 인수 연역이라는 기능을 통해, 함수 호출에 사용된 인수를 보고 컴파일러가 스스로 적절한 타입을 추론하게 할 수 있습니다.
즉, 위와 같이 쓰는 대신 아래 두 가지 방법 중 하나를 사용할 수 있습니다.
std::cout << max<>(1, 2) << '\n';
std::cout << max(1, 2) << '\n';
어느 쪽을 사용하든, 컴파일러는 우리가 실제 타입을 명시하지 않은 것을 확인하고, 전달된 인수(1, 2)를 바탕으로 타입을 추론합니다.
이 예제에서는 인수가 모두 int이므로, 컴파일러는 T를 int로 바꿔서 max<int>(int, int) 함수를 생성하면 매개변수 타입과 인수 타입이 딱 맞아떨어진다고 똑똑하게 판단합니다.
그렇다면 <>를 빈칸으로 남겨두는 것과 아예 생략하는 것의 차이는 뭘까요?
이는 컴파일러가 이름이 같은 여러 함수(오버로딩된 함수) 중 어떤 것을 호출할지 결정하는 방식과 관련이 있습니다.
<> 사용): 컴파일러는 오직 max 템플릿 함수들 중에서만 어떤 것을 호출할지 고려합니다.#include <iostream>
template <typename T>
T max(T x, T y)
{
std::cout << "called max<int>(int, int)\n"; // 템플릿 함수가 호출됨
return (x < y) ? y : x;
}
int max(int x, int y)
{
std::cout << "called max(int, int)\n"; // 일반 함수가 호출됨
return (x < y) ? y : x;
}
int main()
{
std::cout << max<int>(1, 2) << '\n'; // max<int>(int, int)를 호출합니다
std::cout << max<>(1, 2) << '\n'; // max<int>(int, int)를 추론합니다 (일반 함수는 고려 대상 제외)
std::cout << max(1, 2) << '\n'; // 일반 함수인 max(int, int)를 호출합니다
return 0;
}
맨 아래의 max(1, 2) 코드는 일반 함수를 호출할 때의 모습과 완전히 똑같죠?
실제로 대부분의 상황에서는 템플릿 함수를 호출할 때도 이처럼 가장 흔하고 익숙한 일반 함수 호출 문법을 사용합니다.
함수 템플릿은 여러 타입을 다루기 위해 만들어졌기 때문에 그 내용이 '범용적(generic)'이어야 합니다.
반면 일반 함수는 특정 타입들의 조합만 처리하면 됩니다.
따라서 템플릿 버전보다 특정 타입에 맞게 더 최적화되거나 특별한 처리를 할 수 있죠.
#include <iostream>
// 이 함수 템플릿은 여러 타입을 다룰 수 있으므로 범용적으로 구현되어 있습니다.
template <typename T>
void print(T x)
{
std::cout << x; // T가 평소에 출력되는 방식대로 출력합니다.
}
// 이 함수는 오직 bool 타입만 어떻게 출력할지 고려하면 되므로, bool에 특화된 처리를 할 수 있습니다.
void print(bool x)
{
std::cout << std::boolalpha << x; // bool 값을 1이나 0이 아닌 true나 false로 출력합니다.
}
int main()
{
print<bool>(true); // print<bool>(bool) 템플릿 호출 -- 1 출력
std::cout << '\n';
print<>(true); // print<bool>(bool) 템플릿 추론 (일반 함수 제외) -- 1 출력
std::cout << '\n';
print(true); // 일반 함수 print(bool) 호출 -- true 출력
std::cout << '\n';
return 0;
}
모범 사례 (Best practice)
특별히 일반 함수를 무시하고 무조건 템플릿 버전을 호출해야 하는 상황이 아니라면, 함수 템플릿을 호출할 때도 일반 함수 호출 문법(꺾쇠괄호 생략)을 사용하는 것을 권장합니다.
함수 템플릿을 만들 때 템플릿 매개변수 T 와 일반 매개변수 int double 등을 섞어서 사용할 수도 있습니다.
템플릿 매개변수는 어떤 타입이든 맞춰질 수 있고, 일반 매개변수는 일반 함수처럼 고정된 타입으로 작동합니다.
// T는 템플릿 타입 매개변수입니다.
// double은 일반 매개변수입니다.
// 이 매개변수들은 안에서 쓰이지 않으므로 굳이 이름을 짓지 않아도 됩니다.
template <typename T>
int someFcn(T, double)
{
return 5;
}
int main()
{
someFcn(1, 3.4); // someFcn(int, double)과 일치
someFcn(1, 3.4f); // someFcn(int, double)과 일치 -- float(3.4f)이 double로 승급(자동 변환)됨
someFcn(1.2, 3.4); // someFcn(double, double)과 일치
someFcn(1.2f, 3.4); // someFcn(float, double)과 일치
someFcn(1.2f, 3.4f); // someFcn(float, double)과 일치 -- 두 번째 float이 double로 승급됨
return 0;
}
#include <iostream>
#include <string>
template <typename T>
T addOne(T x)
{
return x + 1;
}
int main()
{
std::string hello { "Hello, world!" };
std::cout << addOne(hello) << '\n';
return 0;
}
컴파일러가 addOne(hello)를 처리하려고 할 때, 일치하는 일반 함수는 찾지 못합니다.
하지만 addOne(T) 함수 템플릿이 있으니, 이걸 이용해 addOne(std::string) 함수를 만들어낼 수 있다고 판단하죠.
그래서 컴파일러는 다음 코드를 생성해서 컴파일하려고 시도합니다.
#include <iostream>
#include <string>
template <typename T>
T addOne(T x);
template<>
std::string addOne<std::string>(std::string x)
{
return x + 1; // 여기서 문제 발생!
}
int main()
{
std::string hello{ "Hello, world!" };
std::cout << addOne(hello) << '\n';
return 0;
}
하지만 이 코드는 컴파일 에러를 발생시킵니다.
왜냐하면 x가 문자열 std::string 일 때 x + 1을 한다는 것은 문법적으로 말이 되지 않기 때문입니다.
이 문제를 해결하는 가장 명백한 방법은 아예 addOne() 함수에 std::string 타입의 인수를 넣지 않는 것입니다.
컴파일러는 문법만 맞으면 템플릿을 통해 함수를 성공적으로 만들어냅니다.
하지만 그 함수가 실제로 의미가 있는 행동(의미론적으로 올바른 행동)을 하는지까지는 검사할 능력이 없습니다.
#include <iostream>
template <typename T>
T addOne(T x)
{
return x + 1;
}
int main()
{
std::cout << addOne("Hello, world!") << '\n';
return 0;
}
이 예제에서는 전통적인 C언어 스타일의 문자열 리터럴에 addOne()을 호출하고 있습니다.
문자열 문장에 +1을 더한다니, 이게 도대체 무슨 의미일까요? 아무도 모릅니다!
그런데 놀랍게도, C++ 문법 규칙상 문자열 리터럴에 정수 값을 더하는 행위 자체(포인터 연산)는 허용되기 때문에 위 코드는 정상적으로 컴파일이 되어버립니다. 그리고 이런 엉뚱한 결과를 출력합니다.
ello, world!
고급 독자를 위한 추가 내용
때로는 컴파일러에게 "특정 타입으로는 절대로 이 템플릿을 인스턴스화하지 마!"라고 강제할 수 있습니다.
함수 템플릿 특수화 기능과= delete문법을 조합하면 되는데요, 특정 타입으로 함수를 호출하면 강제로 컴파일 에러를 뿜어내게 만드는 식입니다.#include <iostream> #include <string> template <typename T> T addOne(T x) { return x + 1; } // 함수 템플릿 특수화를 사용하여 컴파일러에게 addOne(const char*) 호출 시 컴파일 에러를 발생시키라고 지시합니다. // const char*는 문자열 리터럴과 일치합니다. template <> const char* addOne(const char* x) = delete; int main() { std::cout << addOne("Hello, world!") << '\n'; // 여기서 컴파일 에러 발생! return 0; }
일반 함수처럼 함수 템플릿도 일반 매개변수에 대해 기본값을 가질 수 있습니다.
템플릿을 통해 생성된 모든 함수들은 똑같은 기본값을 공유해서 사용하게 됩니다.
#include <iostream>
template <typename T>
void print(T val, int times=1) // times를 넘기지 않으면 기본값 1이 사용됨
{
while (times--)
{
std::cout << val;
}
}
int main()
{
print(5); // 5를 1번 출력
print('a', 3); // 'a'를 3번 출력
return 0;
}
출력 결과:
5aaa
이전 7.11 강의에서 다뤘던 '정적 지역 변수'를 기억하시나요? 프로그램이 실행되는 내내 값이 유지되는 변수였죠.
만약 함수 템플릿 안에서 이 정적 지역 변수를 사용하면 어떻게 될까요?
흥미롭게도, 템플릿으로 인해 인스턴스화된 각각의 함수들은 자신만의 독립적인 정적 지역 변수 복사본을 가지게 됩니다.
만약 이 변수가 const라면 큰 문제가 안 되지만, 값을 자꾸 수정하는 용도로 쓴다면 예상과 전혀 다른 결과가 나올 수 있습니다.
#include <iostream>
// 값이 변경되는 정적 지역 변수를 포함하는 함수 템플릿입니다.
template <typename T>
void printIDAndValue(T value)
{
static int id{ 0 };
std::cout << ++id << ") " << value << '\n';
}
int main()
{
printIDAndValue(12);
printIDAndValue(13);
printIDAndValue(14.5);
return 0;
}
결과는 다음과 같습니다.
1) 12
2) 13
1) 14.5
아마 마지막 줄이 3) 14.5가 될 거라고 예상하셨을 수 있습니다.
하지만 컴파일러가 실제로 컴파일하고 실행하는 코드는 아래와 같습니다.
#include <iostream>
template <typename T>
void printIDAndValue(T value);
template <>
void printIDAndValue<int>(int value)
{
static int id{ 0 }; // <int> 함수만의 id
std::cout << ++id << ") " << value << '\n';
}
template <>
void printIDAndValue<double>(double value)
{
static int id{ 0 }; // <double> 함수만의 독립적인 id
std::cout << ++id << ") " << value << '\n';
}
int main()
{
printIDAndValue(12); // printIDAndValue<int>() 호출 (id = 1)
printIDAndValue(13); // printIDAndValue<int>() 호출 (id = 2)
printIDAndValue(14.5); // printIDAndValue<double>() 호출 (id = 1로 새로 시작!)
return 0;
}
보시다시피 printIDAndValue<int> 함수와 printIDAndValue<double> 함수는 이름이 id로 같을 뿐,
서로 공유하지 않는 각자의 독립적인 변수를 가지고 있기 때문에 카운트가 이어지지 않는 것입니다.
템플릿의 T와 같은 타입은 나중에 어떤 실제 자료형으로든 교체될 수 있기 때문에,
종종 제네릭 타입(generic types), 즉 범용 타입이라고 부릅니다.
그리고 이렇게 특정 데이터 타입에 얽매이지 않고 여러 상황에 두루 쓰일 수 있는 템플릿 코드를 작성하는 것을 제네릭 프로그래밍(generic programming)이라고 합니다.
C++는 원래 자료형(타입)을 아주 깐깐하게 따지는 언어지만, 제네릭 프로그래밍을 활용하면 타입의 제약에서 벗어나 "이 함수가 어떤 흐름으로 작동해야 하는지(알고리즘)"나 "데이터를 어떻게 다룰 것인지(자료구조)"에 훨씬 더 집중할 수 있게 해줍니다.
추천하는 방식은 이렇습니다.
11.6 챕터에서 두 값 중 큰 값(max) 을 구하는 함수 템플릿을 만들었습니다.
#include <iostream>
template <typename T>
T max(T x, T y)
{
return (x < y) ? y : x;
}
int main()
{
std::cout << max(1, 2) << '\n'; // max(int, int)를 인스턴스화(생성)함
std::cout << max(1.5, 2.5) << '\n'; // max(double, double)를 인스턴스화(생성)함
return 0;
}
이제 아래처럼 비슷한 코드를 보겠습니다.
#include <iostream>
template <typename T>
T max(T x, T y)
{
return (x < y) ? y : x;
}
int main()
{
std::cout << max(2, 3.5) << '\n'; // 컴파일 오류
return 0;
}
놀랍게도 이 프로그램은 컴파일되지 않습니다. 왜 그럴까요?
호출 max(2, 3.5)에서는 서로 다른 타입 int double을 인자로 넘기고 있습니다.
그리고 우리는 max<double>(...) 처럼 꺾쇠로 타입을 직접 지정하지 않았죠. 그래서 컴파일러는 순서대로 이렇게 시도합니다.
max(int, double)에 딱 맞는 것이 있는지 찾음T는 오직 한 가지 타입만 의미할 수 있기 때문입니다.즉, max<T>(T, T)에서 두 매개변수 타입이 둘 다 T라면, 실제 호출에서도 두 인자는 같은 타입으로 결정되어야 합니다.
하지만 int와 double을 동시에 만족시키는 “하나의 T”는 존재할 수 없죠.
왜 컴파일러가 자동으로 double로 맞춰주지 않을까?
“그럼
max<double>(double, double)를 만들고,2(int)를double로 바꿔서 쓰면 되지 않나?”라고 생각할 수 있습니다.
하지만 답은 이렇습니다.
- 타입 변환(예:
int→double) 은 “함수 오버로드를 고르는 단계”에서만 일어나고,- 템플릿 인자 추론 단계에서는 타입 변환을 하지 않습니다.
이렇게 설계된 이유는 최소 두 가지입니다.
1. 규칙을 단순하게 만들기 위해서: 정확히 맞으면 되고, 아니면 안 된다
2. “두 매개변수 타입이 반드시 같아야 한다” 같은 템플릿을 만들 수 있게 하기 위해서(위 예시처럼)그래서 다른 해결책이 필요합니다. 이 문제는 (최소) 3가지 방법으로 해결할 수 있습니다.
해결책 1:
static_cast로 호출자가 타입을 맞추기
첫 번째 방법은 호출하는 쪽에서 타입을 같게 만들어주는 것입니다.#include <iostream> template <typename T> T max(T x, T y) { return (x < y) ? y : x; } int main() { std::cout << max(static_cast<double>(2), 3.5) << '\n'; // int를 double로 바꿔서 max(double, double)을 호출 return 0; }이제 두 인자가 모두
double이므로, 컴파일러는max(double, double)을 문제없이 만들어서 사용할 수 있습니다.
해결책 2: 템플릿 타입을 “명시적으로” 지정하기
만약 템플릿이 아니라 일반 함수
max(double, double)가 있었다면, 아래 호출은 자동으로int→double변환이 일어나서 잘 동작합니다.#include <iostream> double max(double x, double y) { return (x < y) ? y : x; } int main() { std::cout << max(2, 3.5) << '\n'; // int 인자는 double로 변환됨 return 0; }하지만 템플릿 인자 추론 중에는 변환을 안 한다고 했죠.
대신, 추론을 쓰지 않고 우리가 템플릿 타입을 직접 지정하면 됩니다.#include <iostream> template <typename T> T max(T x, T y) { return (x < y) ? y : x; } int main() { // 타입을 double로 명시했으므로, 컴파일러는 템플릿 인자 추론을 하지 않음 std::cout << max<double>(2, 3.5) << '\n'; return 0; }여기서는
T를double로 “확정”했기 때문에, 컴파일러는max<double>(double, double)를 만들고, 맞지 않는 인자는 그때 자동 변환합니다. 그래서2가double로 바뀌어 들어갑니다.
static_cast보단 읽기 좋지만, 여전히 “호출할 때 타입을 생각해야 하는” 불편함이 남습니다.
해결책 3: 템플릿 타입을 2개 이상 사용하기
문제의 뿌리는 이것입니다.
- 템플릿 타입 매개변수를
T하나만 만들었고,- 두 매개변수 모두
T로 선언해서 둘의 타입이 같아야만 했다는 점그래서 가장 좋은 해결은, 매개변수들이 서로 다른 타입이 될 수 있도록 템플릿 타입 매개변수를 2개로 늘리는 것입니다.
#include <iostream> template <typename T, typename U> // T와 U, 두 개의 템플릿 타입 매개변수를 사용 T max(T x, U y) // x는 T 타입, y는 U 타입으로 각각 따로 결정될 수 있음 { return (x < y) ? y : x; // 어라? 여기서 축소 변환(narrowing conversion) 문제가 생김 } int main() { std::cout << max(2, 3.5) << '\n'; // max<int, double>로 결정됨 return 0; }이제
x는 T,y는 U이므로 서로 독립적으로 타입이 결정됩니다.
max(2, 3.5)를 호출하면T=int,U=double이 가능하니 컴파일러는max<int, double>(int, double)를 만들어줍니다.핵심 포인트(Key insight)
T와U는 서로 독립적인 템플릿 매개변수이므로, 서로 다른 타입으로도 결정될 수 있고, 같은 타입으로도 결정될 수 있습니다.그런데… 실행 결과가 이상하다?
위 코드를 컴파일/실행하면 결과가 이렇게 나올 수 있습니다.
3
2와3.5의 최댓값이3이라니, 왜죠?조건 연산자
?:는 조건 부분을 제외한 두 값(여기서는y와x)이 같은 공통 타입으로 맞춰져야 합니다.
이 공통 타입은 “보통의 산술 변환 규칙”(10.5 챕터)으로 결정됩니다.
int와double의 공통 타입은double- 그래서
(x < y) ? y : x의 결과 자체는double값3.5가 됩니다(여기까진 정상)문제는 함수의 반환 타입을
T로 선언했다는 점입니다.
T=int,U=double인 경우 함수 반환 타입은int가 되고,3.5가 int로 바뀌면서3으로 잘리는 축소 변환이 발생합니다.그럼 반환 타입을
U로 바꾸면 될까요? 그것도 완벽한 해결은 아닙니다. 예를 들어max(3.5, 2)라면U가int가 되어 비슷한 문제가 다시 생길 수 있습니다.반환 타입을
auto로 두고 컴파일러가 추론하게 하기
이럴 때는 반환 타입 추론auto이 유용합니다.return문을 보고 컴파일러가 반환 타입을 정하도록 맡기는 거죠.#include <iostream> template <typename T, typename U> auto max(T x, U y) // 반환 타입을 컴파일러가 알아서 결정하게 함 { return (x < y) ? y : x; } int main() { std::cout << max(2, 3.5) << '\n'; return 0; }이제 서로 다른 타입을 넣어도 잘 동작합니다.
단,
auto반환 타입 함수는 함수 구현(정의)이 먼저 필요합니다.
반환 타입을 알려면 컴파일러가 함수 내용을 봐야 하므로, 선언만 앞에 두는 전방 선언만으로는 부족합니다.
C++20부터는 auto를 매개변수 타입으로 쓰면, 컴파일러가 자동으로 함수 템플릿으로 바꿔줍니다.
이 방식을 축약 함수 템플릿이라고 합니다.
auto max(auto x, auto y)
{
return (x < y) ? y : x;
}
이 코드는 C++20에서 아래와 같은 의미입니다.
template <typename T, typename U>
auto max(T x, U y)
{
return (x < y) ? y : x;
}
즉, 우리가 위에서 만든 “서로 다른 타입을 받을 수 있는 max”와 같습니다.
매개변수마다 독립적인 타입이 필요할 때는 이런 형태가 더 짧고 읽기 쉬워서 선호됩니다.
하지만, 만약 두 개 이상의 매개변수가 반드시 똑같은 타입이어야 한다면 이 축약형 문법으로는 표현하기가 무척 까다롭습니다.
다시 말해, 아래와 같은 코드를 간결하게 뚝딱 만들어내는 축약형 템플릿 문법은 존재하지 않습니다.
template <typename T>
T max(T x, T y) // 두 매개변수가 반드시 동일한 타입이어야 함
{
return (x < y) ? y : x;
}
모범 사례 (Best practice)
매개변수가 하나뿐이거나, 여러 매개변수가 각자 독립적인 타입이어도 상관없다면 축약형 함수 템플릿(auto매개변수)을 적극 활용하세요. (단, 컴파일러 언어 표준 설정이 C++20 이상이어야 합니다).
일반 함수처럼 함수 템플릿도 오버로딩할 수 있습니다.
오버로드들은 템플릿 타입 개수나 매개변수 개수/타입이 달라도 됩니다.
#include <iostream>
// 같은 타입 두 값을 더하기
template <typename T>
auto add(T x, T y)
{
return x + y;
}
// 다른 타입 두 값을 더하기
// C++20부터는 auto add(auto x, auto y)로도 가능
template <typename T, typename U>
auto add(T x, U y)
{
return x + y;
}
// 어떤 타입이든 3개 값을 더하기
// C++20부터는 auto add(auto x, auto y, auto z)로도 가능
template <typename T, typename U, typename V>
auto add(T x, U y, V z)
{
return x + y + z;
}
int main()
{
std::cout << add(1.2, 3.4) << '\n'; // add<double>()를 인스턴스화하고 호출
std::cout << add(5.6, 7) << '\n'; // add<double, int>()를 인스턴스화하고 호출
std::cout << add(8, 9, 10) << '\n'; // add<int, int, int>()를 인스턴스화하고 호출
return 0;
}
여기서 흥미로운 점 하나
add(1.2, 3.4) 호출은 add<T>(T, T)도 맞고 add<T, U>(T, U)도 맞을 수 있습니다.add<T>(T, T)는 “두 매개변수 타입이 같아야 한다”는 제약이 있어서 더 구체적이므로 이쪽이 선택됩니다.여러 템플릿 후보 중 어떤 것을 더 우선할지 결정하는 규칙을 함수 템플릿의 부분 순서(partial ordering of function templates) 라고 부릅니다.
만약 여러 템플릿이 다 맞아 보이는데, 컴파일러가 어느 쪽이 더 구체적인지 판단할 수 없으면 모호한 호출로 컴파일 오류가 납니다.
이전 강의에서는 타입 템플릿 매개변수를 사용하는 함수 템플릿을 만드는 방법을 배웠습니다.
타입 템플릿 매개변수는 “나중에 실제 타입이 들어올 자리”를 미리 만들어 두는 타입용 빈칸이라고 생각하면 됩니다.
그런데 템플릿 매개변수는 타입만 받을 수 있는 게 아닙니다.
알아두면 좋은 또 다른 종류가 있는데, 바로 비타입 템플릿 매개변수입니다.
비타입 템플릿 매개변수는 “타입”이 아니라, 특정 타입을 가진 constexpr 값이 들어올 자리를 만드는 템플릿 매개변수입니다.
즉,
constexpr)”이 들어올 자리비타입 템플릿 매개변수는 다음 같은 타입을 사용할 수 있습니다.
std::bitset을 배울 때 비타입 템플릿 매개변수를 이미 한 번 봤습니다.
#include <bitset>
int main()
{
std::bitset<8> bits{ 0b0000'0101 }; // <8>은 비타입 템플릿 매개변수입니다
return 0;
}
std::bitset에서는 <8>이 “비트를 몇 개 저장할지”를 알려줍니다.
즉, 비트 개수는 컴파일 타임에 정해져야 하므로 constexpr 값이 필요하고, 그래서 비타입 템플릿 매개변수가 쓰입니다.
아래는 int 비타입 템플릿 매개변수를 쓰는 아주 간단한 함수 예시입니다.
#include <iostream>
template <int N> // int 타입의 비타입 템플릿 매개변수 N 선언
void print()
{
std::cout << N << '\n'; // 여기서 N 값을 사용
}
int main()
{
print<5>(); // 5가 비타입 템플릿 인자입니다
return 0;
}
출력:
5
설명:
template <int N>에서 N은 int 타입 값을 담는 자리입니다.print() 함수 안에서는 N을 그냥 값처럼 사용할 수 있습니다.print<5>()를 호출하면, 컴파일러는 사실상 이런 함수를 “인스턴스화해서” 사용합니다:template <>
void print<5>()
{
std::cout << 5 << '\n';
}
실행 중에 main()에서 이 함수가 호출되면 5가 출력되고, 프로그램이 끝납니다. 간단하죠?
참고로, 타입 템플릿 매개변수에서 첫 번째 타입 이름으로 T를 자주 쓰듯이,
int 비타입 템플릿 매개변수 이름으로는 관례적으로 N을 많이 씁니다.
C++20 기준으로, 함수의 매개변수는 constexpr로 만들 수 없습니다.
일반 함수나 constexpr 함수 모두 마찬가지이며(실행 시간에도 실행될 수 있어야 하므로 당연합니다),
놀랍게도 무조건 컴파일 타임에 실행되어야 하는 consteval 함수조차 그렇습니다.
#include <cassert>
#include <cmath> // std::sqrt 사용을 위해
#include <iostream>
double getSqrt(double d)
{
assert(d >= 0.0 && "getSqrt(): d는 음수가 아니어야 합니다");
// 위 assert 구문은 디버그 빌드가 아닐 경우(release 모드 등) 아마 컴파일에서 제외될 것입니다.
if (d >= 0)
return std::sqrt(d);이 프로그램을 실행하면 getSqrt(-5.0) 호출에서 런타임 assert가 터집니다.
return 0.0;
}
int main()
{
std::cout << getSqrt(5.0) << '\n';
std::cout << getSqrt(-5.0) << '\n';
return 0;
}
이 프로그램을 실행하면 getSqrt(-5.0) 호출에서 런타임 assert가 터집니다.
이것도 아무것도 없는 것보다는 낫지만, -5.0은 리터럴이라 사실상 컴파일 타임에 알 수 있는 값이니,
가능하다면 static_assert로 컴파일 타임에 잡아내는 편이 더 좋겠죠.
하지만 static_assert는 상수 표현식이 필요하고,
함수 매개변수 d는 constexpr가 될 수 없어서 static_assert(d >= 0.0) 같은 걸 할 수 없습니다.
그런데 함수 매개변수를 비타입 템플릿 매개변수로 바꾸면, 우리가 원하는 걸 할 수 있습니다.
#include <cmath> // for std::sqrt
#include <iostream>
template <double D> // 부동소수점 비타입 매개변수는 C++20 필요
double getSqrt()
{
static_assert(D >= 0.0, "getSqrt(): D must be non-negative");
if constexpr (D >= 0) // 이번 예제에서는 constexpr는 그냥 무시해도 됩니다
return std::sqrt(D); // 이상하게도 std::sqrt는 (C++26 전까지) constexpr 함수가 아닙니다
return 0.0;
}
int main()
{
std::cout << getSqrt<5.0>() << '\n';
std::cout << getSqrt<-5.0>() << '\n';
return 0;
}