타입을 덜 쓰면 코드가 흐려질까?
템플릿을 처음 배우면 이런 생각이 듭니다. "타입을 직접 쓰지 않아도 된다니 편하긴 한데, 그러면 코드가 대충대충 되는 것 아닌가?"
C++의 타입 추론은 대충 맞히는 기능이 아닙니다. 오히려 반대에 가깝습니다. 컴파일러는 초기화 표현식, 함수 호출 인자, 반환 표현식 등을 보고 매우 엄격한 규칙에 따라 타입을 결정합니다. 개발자가 긴 타입 이름을 직접 쓰지 않을 뿐, 컴파일러는 여전히 정확한 타입을 알고 있습니다.
std::vector<std::string>::iterator it = names.begin();이 코드는 틀리지 않습니다. 하지만 읽는 사람은 타입 이름에 먼저 압도됩니다. 실제로 중요한 것은 "names의 시작 위치를 가리키는 반복자"라는 사실입니다.
auto it = names.begin();이렇게 쓰면 코드가 훨씬 가벼워집니다. auto는 "아무 타입이나"라는 뜻이 아닙니다. names.begin()의 결과 타입을 컴파일러가 정확히 추론하라는 뜻입니다.
이 장에서 배울 핵심은 단순히 auto를 편하게 쓰는 법이 아닙니다. 타입 이름보다 코드가 요구하는 행동에 집중하는 감각입니다. 제네릭 코드는 타입을 지우는 코드가 아니라, 여러 타입이 공유하는 공통 행동을 정확히 활용하는 코드입니다.

auto는 초기화 표현식에서 출발한다
auto는 변수를 선언할 때 초기화 표현식을 보고 타입을 추론합니다.
auto age = 20; // int
auto price = 19.99; // double
auto name = std::string{"Mina"}; // std::string여기서 중요한 점은 auto 변수를 선언할 때 반드시 초기값이 필요하다는 것입니다.
auto x; // 오류: 무엇을 보고 타입을 추론해야 할지 알 수 없다auto는 마법의 빈칸이 아닙니다. 오른쪽에 있는 표현식을 보고 왼쪽의 타입을 채워 넣는 규칙입니다.
int n = 10;
auto a = n; // int이 정도는 쉽습니다. 하지만 const와 참조가 섞이면 조금 더 조심해야 합니다.
int n = 10;
const int cn = 20;
int& ref = n;
auto a = n; // int
auto b = cn; // int
auto c = ref; // int놀랍게도 b는 const int가 아니라 int입니다. c도 int&가 아니라 int입니다. 왜 그럴까요?
auto를 값으로 선언하면, 보통 값을 하나 복사해서 새 변수를 만든다고 생각하면 됩니다. 복사해서 만든 새 변수는 원래 변수가 const였는지, 참조였는지에 직접 묶여 있을 필요가 없습니다.
const int cn = 20;
auto b = cn;
b = 30; // 가능. b는 int다.cn 자체는 여전히 수정할 수 없습니다. 다만 b는 cn의 값을 복사해서 만든 새로운 int 변수입니다.
참조도 마찬가지입니다.
int n = 10;
int& ref = n;
auto c = ref;
c = 50; // c만 바뀐다c는 ref가 가리키던 n을 함께 가리키는 참조가 아닙니다. n의 값을 복사해서 만든 별도의 int입니다.
참조를 유지하고 싶다면 auto&를 써야 합니다.
int n = 10;
int& ref = n;
auto& r = ref;
r = 50;
std::cout << n << std::endl; // 50const까지 유지하고 싶다면 const auto&를 사용합니다.
const std::string name = "Mina";
const auto& refName = name;
// refName += " Kim"; // 오류: const 참조이므로 수정할 수 없다읽기만 할 때는 const auto&가 매우 자주 쓰입니다. 특히 문자열, 벡터, 사용자 정의 객체처럼 복사 비용이 있을 수 있는 타입을 다룰 때 유용합니다.
std::vector<std::string> names{"Mina", "Joon", "Hana"};
for (const auto& name : names) {
std::cout << name << std::endl;
}여기서 auto만 쓰면 각 문자열이 복사됩니다.
for (auto name : names) {
std::cout << name << std::endl;
}코드는 동작하지만, 매 반복마다 문자열을 복사합니다. 작은 예제에서는 별일 아니지만, 큰 객체가 들어 있는 컨테이너에서는 비용이 커질 수 있습니다.
반대로 원소를 수정하려면 auto&를 씁니다.
std::vector<int> scores{70, 80, 90};
for (auto& score : scores) {
score += 5;
}범위 기반 for문에서 auto를 고르는 감각은 다음처럼 정리할 수 있습니다.
auto는 원소를 복사해서 사용할 때 쓴다.auto&는 원소를 직접 수정할 때 쓴다.const auto&는 복사하지 않고 읽기만 할 때 쓴다.
현대 C++ 코드에서 const auto&는 아주 흔한 모양입니다. "타입 이름은 길고, 나는 읽기만 할 것이다"라는 의도를 간결하게 표현하기 때문입니다.
auto를 쓸 때도 의도는 선명해야 한다
auto는 편하지만, 모든 곳에 무조건 쓰는 것이 좋은 습관은 아닙니다. 타입 이름이 코드의 의미를 설명해 주는 경우도 있기 때문입니다.
auto count = 0;이 변수의 타입은 int입니다. 대부분 문제없지만, 컨테이너의 크기와 비교할 때는 조심해야 합니다.
std::vector<int> values{1, 2, 3};
auto count = values.size();이번에는 count의 타입이 std::vector<int>::size_type입니다. 보통 부호 없는 정수 타입입니다. 같은 auto count처럼 보여도 오른쪽 표현식에 따라 타입은 달라집니다.
그래서 auto를 쓸 때는 "내가 타입을 몰라도 된다"가 아니라 "오른쪽 표현식만 봐도 타입의 의미가 충분히 드러난다"라고 판단해야 합니다.
auto user = loadUser();loadUser()라는 함수 이름이 충분히 명확하다면 좋습니다. 하지만 함수 이름이 모호하거나 반환 타입이 중요한 문맥이라면 타입을 직접 쓰는 편이 더 읽기 쉬울 수 있습니다.
User user = loadUser();또 하나 조심할 것은 중괄호 초기화입니다.
auto a = 10; // int
auto b{10}; // int
auto c = {10}; // std::initializer_list<int>auto c = {10};은 단순한 int가 아닙니다. 초기화 리스트라는 특별한 타입으로 추론됩니다. 여러 값을 넣으면 더 분명해집니다.
auto numbers = {1, 2, 3}; // std::initializer_list<int>초보 단계에서는 auto와 중괄호를 섞어 쓸 때 예상과 다른 타입이 나올 수 있다는 정도만 기억해도 충분합니다.
decltype은 표현식의 타입을 보존한다
auto가 "초기화해서 새 변수를 만들 때 적당히 자연스러운 타입"을 추론한다면, decltype은 조금 더 날카로운 도구입니다. decltype은 표현식의 타입을 알아내는 기능입니다.
int x = 10;
decltype(x) y = 20; // int여기까지만 보면 auto와 비슷해 보입니다. 하지만 decltype은 참조와 const를 더 정확히 보존할 수 있습니다.
int n = 10;
int& ref = n;
const int cn = 20;
decltype(n) a = 1; // int
decltype(ref) b = n; // int&
decltype(cn) c = 2; // const intdecltype(ref)는 int가 아니라 int&입니다. auto c = ref;였다면 참조가 사라졌겠지만, decltype(ref)는 선언된 타입을 그대로 봅니다.
그런데 decltype에서 가장 헷갈리는 지점은 괄호입니다.
int x = 10;
decltype(x) a = 1; // int
decltype((x)) b = x; // int&decltype(x)는 변수 x의 선언된 타입을 봅니다. 그래서 int입니다.
반면 decltype((x))는 괄호로 감싼 표현식 (x)의 타입을 봅니다. x는 이름이 있는 변수이고, 이름이 있는 변수 표현식은 lvalue입니다. lvalue는 "메모리 어딘가에 자리 잡고 있어서 다시 접근할 수 있는 값"이라고 생각하면 됩니다. 그래서 decltype((x))는 int&가 됩니다.
처음에는 이 차이가 지나치게 까다로워 보일 수 있습니다. 지금 당장 모든 규칙을 외우려 하지 않아도 됩니다. 다만 다음 두 문장은 꼭 기억해 두면 좋습니다.
decltype(name)은 대체로 그 이름의 선언된 타입을 본다.decltype((expr))처럼 일반 표현식을 보면 값 범주까지 반영해 참조 타입이 나올 수 있다.
이 규칙은 나중에 템플릿 메타프로그래밍이나 고급 라이브러리 코드를 읽을 때 중요해집니다. 입문 단계에서는 "decltype은 auto보다 타입을 더 그대로 보존하려는 도구"라고 이해하면 충분합니다.
반환 타입에서 decltype이 빛나는 순간
두 값을 더하는 함수를 템플릿으로 만들어 봅시다.
template <typename A, typename B>
auto add(A a, B b) {
return a + b;
}현대 C++에서는 이렇게 auto 반환 타입을 사용할 수 있습니다. 컴파일러가 return a + b;를 보고 반환 타입을 추론합니다.
하지만 반환 타입을 명시적으로 표현해야 하는 상황도 있습니다. 이때 decltype이 유용합니다.
template <typename A, typename B>
auto add(A a, B b) -> decltype(a + b) {
return a + b;
}뒤에 붙은 -> decltype(a + b)를 후행 반환 타입이라고 합니다. 함수 이름 앞에서 반환 타입을 쓰는 대신, 매개변수를 쓴 뒤에 반환 타입을 적는 방식입니다.
왜 굳이 뒤에 쓸까요?
template <typename A, typename B>
decltype(a + b) add(A a, B b) { // 오류: 이 위치에서는 a와 b를 아직 모른다
return a + b;
}함수 이름 앞에서는 아직 매개변수 a, b가 선언되기 전입니다. 따라서 a + b라는 표현식을 쓸 수 없습니다. 후행 반환 타입을 쓰면 매개변수 선언 뒤에 반환 타입을 적으므로 a와 b를 사용할 수 있습니다.
요즘은 단순한 경우에 auto 반환 타입만으로 충분합니다.
template <typename A, typename B>
auto add(A a, B b) {
return a + b;
}하지만 decltype은 반환 타입을 정확히 보존하고 싶을 때 여전히 중요합니다.
template <typename Container>
decltype(auto) front(Container& c) {
return c.front();
}여기서 decltype(auto)는 auto처럼 추론하되, decltype의 규칙으로 타입을 보존하라는 뜻입니다.
std::vector<int> values{10, 20, 30};
front(values) = 99;
std::cout << values[0] << std::endl; // 99std::vector<int>::front()는 첫 번째 원소에 대한 참조를 반환합니다. decltype(auto)를 쓰면 그 참조가 보존됩니다.
반면 다음처럼 auto만 쓰면 반환값이 복사될 수 있습니다.
template <typename Container>
auto frontCopy(Container& c) {
return c.front();
}frontCopy(values)는 첫 번째 원소의 복사본을 반환합니다. 원본을 수정할 수 없습니다.
// frontCopy(values) = 99; // 오류: 복사된 임시값에는 대입할 수 없다물론 decltype(auto)는 강력한 만큼 위험할 수도 있습니다.
decltype(auto) bad() {
int n = 10;
return (n); // int&로 추론된다. 지역 변수 참조를 반환하므로 위험하다.
}return (n);에서 괄호 때문에 decltype((n)) 규칙이 적용되어 int&가 됩니다. 그런데 n은 함수가 끝나면 사라지는 지역 변수입니다. 사라진 변수를 참조하게 되므로 매우 위험합니다.
따라서 decltype(auto)를 쓸 때는 "참조를 보존할 필요가 있는가?"를 반드시 생각해야 합니다. 단순히 값을 반환하는 함수라면 보통 auto가 더 안전하고 읽기 쉽습니다.
템플릿 매개변수 추론은 호출에서 시작된다
함수 템플릿에서 T는 어떻게 결정될까요?
template <typename T>
void print(T value) {
std::cout << value << std::endl;
}
print(10); // T는 int
print(3.14); // T는 double
print(std::string{"Hi"}); // T는 std::string컴파일러는 함수 호출 인자를 보고 T를 결정합니다. 이것을 템플릿 매개변수 추론이라고 합니다.
auto와 비슷하게, 값으로 받는 템플릿 매개변수는 참조와 최상위 const를 보통 제거합니다.
template <typename T>
void byValue(T value) {
}
int n = 10;
const int cn = 20;
int& ref = n;
byValue(n); // T는 int
byValue(cn); // T는 int
byValue(ref); // T는 intbyValue는 값을 복사해서 받습니다. 그래서 const int를 넘겨도 함수 안의 value는 별도의 int입니다.
참조로 받으면 이야기가 달라집니다.
template <typename T>
void byRef(T& value) {
}
int n = 10;
const int cn = 20;
byRef(n); // T는 int, value는 int&
byRef(cn); // T는 const int, value는 const int&T&는 원본에 직접 연결됩니다. 따라서 const인 원본을 넘기면 그 성질이 유지됩니다.
읽기만 할 때 가장 자주 쓰는 형태는 const T&입니다.
template <typename T>
void printConstRef(const T& value) {
std::cout << value << std::endl;
}이 형태는 복사를 피하면서도 함수 안에서 값을 수정하지 않겠다는 의도를 드러냅니다. 문자열, 벡터, 사용자 정의 객체를 받을 때 특히 유용합니다.
std::string message = "hello";
printConstRef(message); // 복사하지 않는다
printConstRef(std::string{"temporary"}); // 임시 객체도 받을 수 있다배열에서는 또 하나의 차이가 나타납니다.
template <typename T>
void valueParam(T value) {
}
template <typename T, std::size_t N>
void arrayRef(T (&arr)[N]) {
std::cout << N << std::endl;
}
int scores[3]{10, 20, 30};
valueParam(scores); // T는 int*로 추론된다
arrayRef(scores); // T는 int, N은 3으로 추론된다배열을 값으로 넘기면 포인터처럼 변환됩니다. 하지만 배열 참조로 받으면 배열의 크기까지 타입 정보로 보존할 수 있습니다. C++의 타입 시스템은 생각보다 많은 정보를 담고 있습니다. 제네릭 코드는 그 정보를 얼마나 잘 활용하느냐의 문제입니다.
값 범주: 이름 있는 값과 곧 사라질 값
완벽 전달을 이해하려면 값 범주를 조금 알아야 합니다. 너무 깊게 들어가면 복잡하므로 지금은 두 가지 감각만 잡아도 됩니다.
std::string name = "Mina";name은 lvalue입니다. 이름이 있고, 다시 접근할 수 있으며, 메모리 어딘가에 자리를 잡고 있습니다.
std::string{"Mina"}이 표현식은 rvalue입니다. 보통 임시 객체처럼 곧 사라질 값입니다. 이런 값은 필요하다면 내부 자원을 "이동"해도 됩니다.
이전 장에서 배운 이동 의미론을 떠올려 봅시다. 복사보다 이동이 효율적인 타입이 있습니다. 예를 들어 큰 문자열이나 벡터는 내부 버퍼를 통째로 넘기는 편이 새로 복사하는 것보다 빠를 수 있습니다.
그런데 제네릭 함수가 중간에서 값을 받아 다른 함수로 넘길 때 문제가 생깁니다.
void process(const std::string& value) {
std::cout << "copy-friendly process: " << value << std::endl;
}
void process(std::string&& value) {
std::cout << "move-friendly process: " << value << std::endl;
}하나는 lvalue나 읽기 전용 값에 어울리는 함수이고, 다른 하나는 rvalue를 받아 이동할 수 있는 함수입니다.
이제 중간에 로그를 찍고 process로 넘기는 함수를 만들어 봅시다.
template <typename T>
void logAndProcess(T&& value) {
std::cout << "start" << std::endl;
process(value);
}겉으로 보면 T&&이므로 rvalue를 잘 받을 것 같습니다. 하지만 함수 안에서 value는 이름이 있습니다. 이름이 있는 변수는 lvalue입니다. 따라서 rvalue를 넘겨도 process(value)는 lvalue처럼 처리됩니다.
logAndProcess(std::string{"hello"});호출할 때는 임시 문자열을 넘겼지만, 함수 안의 value는 이름이 있는 매개변수입니다. 그래서 rvalue 성질이 사라집니다.
이때 필요한 것이 std::forward입니다.
#include <utility>
void process(const std::string& value) {
std::cout << "copy-friendly process: " << value << std::endl;
}
void process(std::string&& value) {
std::cout << "move-friendly process: " << value << std::endl;
}
template <typename T>
void logAndProcess(T&& value) {
std::cout << "start" << std::endl;
process(std::forward<T>(value));
}std::forward<T>(value)는 원래 들어온 값의 성질을 보존해서 다음 함수로 넘깁니다. lvalue가 들어왔으면 lvalue처럼, rvalue가 들어왔으면 rvalue처럼 전달합니다.
std::string text = "hello";
logAndProcess(text); // lvalue로 전달
logAndProcess(std::string{"world"}); // rvalue로 전달이것을 완벽 전달이라고 부릅니다. 이름은 거창하지만 핵심은 단순합니다. 제네릭 함수가 값을 중간에서 가로채더라도, 원래 값이 lvalue였는지 rvalue였는지를 잃어버리지 않게 하는 것입니다.
T&&은 항상 rvalue 참조가 아니다
여기서 많은 사람이 함정에 빠집니다.
template <typename T>
void f(T&& value) {
}이 코드의 T&&은 단순한 rvalue 참조가 아닙니다. T가 추론되는 문맥에서의 T&&은 전달 참조라고 부릅니다. 예전에는 보편 참조라고 부르기도 했습니다.
int n = 10;
f(n); // lvalue도 받을 수 있다
f(10); // rvalue도 받을 수 있다어떻게 둘 다 가능할까요? C++에는 참조 겹침을 정리하는 규칙이 있습니다. 자세한 표를 모두 외울 필요는 없지만, 직관적으로는 &가 섞이면 lvalue 참조가 이긴다고 생각하면 됩니다.
lvalue인 n을 넘기면 T는 int&처럼 추론됩니다. 그러면 T&&은 int& && 모양이 되고, 이것은 최종적으로 int&가 됩니다.
rvalue인 10을 넘기면 T는 int로 추론됩니다. 그러면 T&&은 int&&가 됩니다.
그래서 T&&은 제네릭 함수에서 값 범주를 보존하는 입구가 됩니다.
template <typename T>
void wrapper(T&& value) {
target(std::forward<T>(value));
}이 패턴은 현대 C++ 라이브러리에서 매우 자주 보입니다. 특히 객체를 내부에서 직접 생성하는 emplace 계열 함수와 잘 어울립니다.
주의할 점도 있습니다. 완벽 전달을 해야 하는 자리에서 std::move를 쓰면 안 됩니다.
template <typename T>
void wrongWrapper(T&& value) {
target(std::move(value)); // 항상 rvalue처럼 넘긴다
}이렇게 하면 lvalue를 넘긴 경우에도 강제로 이동 대상이 됩니다. 호출한 쪽은 아직 값을 쓸 생각이었는데, 함수 안에서 자원이 빠져나갈 수 있습니다.
std::move는 "이제 이 객체의 자원을 옮겨도 된다"는 의도를 표현합니다. std::forward는 "처음 들어온 성질을 그대로 유지하겠다"는 의도를 표현합니다. 둘은 비슷하게 생겼지만 역할이 다릅니다.
제네릭 코드는 타입 이름보다 요구사항을 본다
제네릭 함수를 잘 만들려면 먼저 질문을 바꿔야 합니다.
"이 함수는 어떤 타입을 받아야 하지?"보다 "이 함수는 인자에게 어떤 행동을 요구하지?"를 먼저 생각해야 합니다.
예를 들어 모든 원소를 출력하는 함수가 있다고 합시다.
template <typename Range>
void printAll(const Range& range) {
for (const auto& item : range) {
std::cout << item << std::endl;
}
}이 함수는 std::vector만 받겠다고 말하지 않습니다. 대신 다음 행동을 요구합니다.
range를 범위 기반for문으로 순회할 수 있어야 한다.각 원소를
std::cout으로 출력할 수 있어야 한다.출력 과정에서 원소를 수정하지 않아도 되어야 한다.
따라서 std::vector<int>, std::list<std::string>, std::array<double, 3>처럼 조건을 만족하는 타입이라면 사용할 수 있습니다.
std::vector<int> numbers{1, 2, 3};
std::list<std::string> names{"Mina", "Joon"};
printAll(numbers);
printAll(names);물론 모든 타입이 가능한 것은 아닙니다. 원소를 출력할 수 없는 타입이라면 컴파일 오류가 납니다. 제네릭 코드는 타입 검사를 없애는 것이 아니라, 타입 검사를 더 일반적인 형태로 미루는 것입니다.
검색 함수도 비슷하게 만들 수 있습니다.
#include <algorithm>
#include <iterator>
template <typename Range, typename Value>
bool contains(const Range& range, const Value& value) {
return std::find(std::begin(range), std::end(range), value) != std::end(range);
}이 함수는 std::vector 전용이 아닙니다. std::begin(range)와 std::end(range)가 가능하고, 원소와 value를 비교할 수 있으면 됩니다.
std::vector<int> numbers{1, 2, 3};
std::array<std::string, 3> names{"Mina", "Joon", "Hana"};
std::cout << contains(numbers, 2) << std::endl;
std::cout << contains(names, std::string{"Joon"}) << std::endl;여기서 중요한 것은 Range라는 이름이 아닙니다. 실제 요구사항은 "처음과 끝을 얻을 수 있고, 그 사이를 순회할 수 있으며, 원소를 비교할 수 있는 것"입니다.
다음 장에서 배울 Concepts는 바로 이런 요구사항에 이름을 붙이고, 더 읽기 좋은 오류 메시지를 만들기 위한 도구입니다. 지금은 Concepts 없이도 제네릭 코드의 기본 사고방식을 충분히 익힐 수 있습니다.
타입에 덜 의존하는 작은 도구 만들기
이번에는 조금 더 실전적인 예제를 보겠습니다. 상품 목록에서 조건에 맞는 상품을 출력하고, 상품을 추가하는 작은 도구를 만들어 봅시다.
#include <iostream>
#include <string>
#include <utility>
#include <vector>
struct Item {
std::string name;
int price;
int stock;
Item(std::string name, int price, int stock)
: name(std::move(name)), price(price), stock(stock) {
}
};
template <typename Range, typename Predicate>
void printItemsIf(const Range& items, Predicate pred) {
for (const auto& item : items) {
if (pred(item)) {
std::cout << item.name << " / "
<< item.price << " / "
<< item.stock << std::endl;
}
}
}
template <typename Container, typename... Args>
decltype(auto) addItem(Container& container, Args&&... args) {
return container.emplace_back(std::forward<Args>(args)...);
}
int main() {
std::vector<Item> items;
addItem(items, "Keyboard", 50000, 10);
addItem(items, "Mouse", 30000, 0);
addItem(items, "Monitor", 200000, 3);
printItemsIf(items, [](const auto& item) {
return item.stock > 0;
});
}아직 람다는 뒤에서 더 자세히 배우겠지만, 여기서는 [](const auto& item) { return item.stock > 0; }가 "재고가 있는 상품인지 검사하는 작은 함수"라고 생각하면 됩니다.
이 예제에는 타입 추론과 제네릭 코드의 감각이 여러 군데 들어 있습니다.
printItemsIf는 컨테이너 타입을 std::vector<Item>으로 고정하지 않았습니다. 범위 기반 for문으로 순회할 수 있고, 각 원소에 name, price, stock이 있다면 사용할 수 있습니다. 완전히 아무 타입이나 받는 것이 아니라, 함수가 실제로 사용하는 인터페이스를 만족하는 타입을 받는 것입니다.
addItem은 Args&&...와 std::forward를 사용합니다. 인자를 중간에서 받았다가 emplace_back으로 그대로 전달하기 위해서입니다. 문자열 리터럴, 임시 문자열, 이미 만들어진 문자열 등 다양한 형태의 인자를 불필요한 복사 없이 넘길 수 있습니다.
decltype(auto)는 emplace_back의 반환 타입을 보존합니다. C++17 이후 std::vector::emplace_back은 추가된 원소에 대한 참조를 반환합니다. 따라서 다음처럼 방금 추가한 원소를 바로 수정할 수도 있습니다.
auto& item = addItem(items, "Speaker", 80000, 5);
item.stock += 2;이 코드는 "컨테이너에 원소를 추가한다"는 작업을 특정 타입 이름에 묶어 두지 않습니다. 대신 컨테이너가 emplace_back을 제공해야 한다는 요구사항에 기대고 있습니다.
이것이 제네릭 코드의 핵심입니다. 타입 이름을 지우는 것이 아니라, 필요한 행동을 중심으로 코드를 세우는 것입니다.
auto, decltype, 템플릿 추론의 관계
이쯤에서 세 도구의 역할을 한 번 정리해 봅시다.
auto는 변수 선언에서 초기화 표현식을 보고 타입을 추론합니다.
auto value = makeValue();decltype은 표현식의 타입을 물어봅니다.
decltype(makeValue()) value = makeValue();템플릿 매개변수 추론은 함수 호출 인자를 보고 템플릿 인자를 결정합니다.
template <typename T>
void use(T value) {
}
use(makeValue());셋은 서로 다른 문법이지만, 모두 "표현식에서 타입 정보를 얻는다"는 공통점을 가집니다. C++을 오래 쓰다 보면 타입 이름을 직접 적는 코드보다, 컴파일러가 이미 알고 있는 타입을 자연스럽게 따라가게 하는 코드가 더 많아집니다.
하지만 추론에만 모든 것을 맡기면 안 됩니다. 추론은 규칙대로 움직일 뿐 개발자의 의도를 읽지는 못합니다.
auto x = 1.0; // double정말 double이 필요했다면 좋습니다. 하지만 금액을 정수 원 단위로 다뤄야 했다면 잘못된 선택입니다.
int price = 1.0; // 의도적으로 정수로 다룬다는 사실이 드러난다타입 추론은 의도를 숨기기 위한 기능이 아니라, 불필요한 반복을 줄여 의도를 더 잘 보이게 만드는 기능입니다.
흔한 실수들
타입 추론을 배우는 단계에서 자주 만나는 실수를 살펴봅시다.
첫 번째는 auto가 항상 참조를 보존한다고 생각하는 것입니다.
std::vector<std::string> names{"Mina", "Joon"};
for (auto name : names) {
name += "!";
}이 코드는 원본 벡터의 문자열을 바꾸지 않습니다. name은 복사본입니다. 원본을 바꾸려면 auto&를 써야 합니다.
for (auto& name : names) {
name += "!";
}읽기만 한다면 const auto&가 좋습니다.
for (const auto& name : names) {
std::cout << name << std::endl;
}두 번째는 decltype의 괄호 차이를 무시하는 것입니다.
int x = 10;
decltype(x) a = 20; // int
decltype((x)) b = x; // int&괄호 하나로 타입이 달라질 수 있습니다. 특히 decltype(auto) 반환에서 이 차이는 실제 버그로 이어질 수 있습니다.
세 번째는 완벽 전달 자리에서 std::move를 사용하는 것입니다.
template <typename T>
void wrapper(T&& value) {
target(std::move(value)); // lvalue도 이동시켜 버릴 수 있다
}제네릭 래퍼에서 원래 값 범주를 보존해야 한다면 std::forward<T>를 써야 합니다.
template <typename T>
void wrapper(T&& value) {
target(std::forward<T>(value));
}네 번째는 제네릭 함수를 지나치게 일반적으로 만들려는 것입니다.
template <typename T>
void doEverything(T value) {
// 무엇이든 다 하려는 함수
}좋은 제네릭 함수는 "무엇이든 받을 수 있는 함수"가 아닙니다. "필요한 요구사항이 분명한 함수"입니다. 정렬하려면 비교 가능해야 하고, 출력하려면 출력 연산이 가능해야 하며, 순회하려면 시작과 끝을 얻을 수 있어야 합니다.
좋은 제네릭 함수를 위한 질문
제네릭 코드를 작성하기 전에 잠시 멈추고 다음 질문을 던져 보면 좋습니다.
이 함수는 인자를 복사해야 하는가, 참조로 받으면 되는가?
함수 안에서 인자를 수정해야 하는가, 읽기만 하면 되는가?
반환할 때 값을 복사해야 하는가, 참조를 보존해야 하는가?
인자 타입이 반드시 특정 컨테이너여야 하는가, 순회 가능하기만 하면 되는가?
lvalue와 rvalue를 구분해서 보존해야 하는가?
이 질문에 답하다 보면 자연스럽게 auto, auto&, const auto&, decltype(auto), T, T&, const T&, T&& 중 무엇을 써야 할지 감이 잡힙니다.
예를 들어 단순히 출력만 한다면 const T&가 좋습니다.
template <typename T>
void print(const T& value) {
std::cout << value << std::endl;
}값을 내부에 저장해야 한다면 복사하거나 이동해야 하므로 값으로 받는 방식도 고려할 수 있습니다.
class User {
private:
std::string name_;
public:
explicit User(std::string name)
: name_(std::move(name)) {
}
};이 생성자는 문자열을 값으로 받은 뒤 멤버로 이동합니다. 호출자가 lvalue를 넘기면 한 번 복사되고, rvalue를 넘기면 이동될 수 있습니다. 간단하면서도 실용적인 패턴입니다.
중간 전달이 목적이라면 T&&와 std::forward를 사용합니다.
template <typename T>
void passToTarget(T&& value) {
target(std::forward<T>(value));
}반환 타입에서 참조를 보존해야 한다면 decltype(auto)를 조심스럽게 사용합니다.
template <typename Container>
decltype(auto) first(Container& c) {
return c.front();
}이처럼 타입 추론은 외워서 쓰는 문법이 아니라, 데이터가 어떻게 흘러가는지를 표현하는 방법입니다.
타입을 몰라도 되는 것이 아니라, 타입을 믿는 것이다
C++의 타입 추론은 동적 언어의 "아무거나 넣어 보자"와 다릅니다. 컴파일러는 모든 타입을 컴파일 시간에 결정하고 검사합니다. 잘못된 연산을 하면 실행 전에 오류를 냅니다.
std::vector<int> numbers{1, 2, 3};
printAll(numbers); // 가능: int는 출력할 수 있다하지만 출력할 수 없는 타입이라면 컴파일 오류가 납니다.
struct Secret {
int code;
};
std::vector<Secret> secrets{{1}, {2}};
// printAll(secrets); // 오류: Secret을 std::cout으로 출력하는 방법이 없다이 오류는 불편해 보이지만, 사실은 C++의 강점입니다. 잘못된 사용을 실행 전에 잡아 주기 때문입니다.
제네릭 코드는 타입을 잊는 코드가 아닙니다. 타입의 이름에 덜 의존하면서도, 타입이 제공해야 하는 행동을 컴파일 시간에 엄격히 확인하는 코드입니다.
이제 우리는 다음 단계로 갈 준비가 되었습니다. 지금까지는 함수가 요구하는 조건이 코드 속에 암시적으로 숨어 있었습니다. printAll은 "순회 가능하고 출력 가능해야 한다"는 조건을 가지고 있지만, 함수 선언만 봐서는 그 조건이 뚜렷하게 드러나지 않습니다.
다음 장에서 배울 Concepts는 이 요구사항을 코드에 직접 적는 방법입니다. 템플릿 오류를 조금 더 읽기 쉽게 만들고, 제네릭 코드의 의도를 더 선명하게 표현하는 현대 C++의 중요한 도구입니다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.
