C++ auto와 decltype: 타입 추론 규칙, decltype(auto), AAA 스타일
초보자를 위한 한 줄:
auto는 초기화식을 보고 타입을 정합니다.auto만 쓰면 복사가 될 수 있으므로 컨테이너 순회는const auto&를 기본으로 떠올리면 됩니다.decltype은 “이 식의 타입 그대로”가 필요할 때 씁니다. 11-3 stringstream 다음 흐름에 맞습니다.
들어가며: 긴 타입 이름 문제
STL(Standard Template Library, 표준 템플릿 라이브러리) 컨테이너를 쓰다 보면 반복자 타입 이름이 매우 길어집니다. 아래 코드는 std::map<...>::iterator와 const_iterator를 풀어 쓴 예입니다. 컨테이너를 std::unordered_map으로 바꾸면 반복자 타입도 바뀌므로, 이렇게 적어 둔 곳을 모두 찾아 고쳐야 합니다.
std::map<std::string, std::vector<int>> data;
// 타입 이름이 너무 김
std::map<std::string, std::vector<int>>::iterator it = data.begin();
// 컨테이너 타입이 바뀌면 여기도 수정해야 함
std::vector<std::pair<std::string, int>> results;
for (std::vector<std::pair<std::string, int>>::const_iterator it = results.begin();
it != results.end(); ++it) {
// ...
}
auto를 쓰면 컴파일러가 begin()의 반환 타입으로 반복자 타입을 추론하므로, 컨테이너를 바꿔도 이 선언들은 그대로 둘 수 있습니다.
std::map<std::string, std::vector<int>> data;
auto it = data.begin();
std::vector<std::pair<std::string, int>> results;
for (auto it = results.begin(); it != results.end(); ++it) {
// ...
}
// 범위 기반 for + 구조화된 바인딩 (C++17)
for (const auto& [key, value] : data) {
// ...
}
auto가 특히 필요한 곳은 람다입니다. 람다는 컴파일러가 만드는 고유 타입을 가지므로 이름을 직접 쓸 수 없고, auto lambda = [](int x) { return x * 2; };처럼 auto(또는 타입을 지우는 std::function)로만 저장할 수 있습니다.
반대로 auto는 타입을 숨기기 때문에 가독성을 해칠 수도 있습니다. auto x = getValue();만 보면 x의 타입을 알기 어렵습니다. 반복자, 람다, 길게 중첩된 템플릿 타입에는 auto를 쓰고, 타입이 드러나는 편이 좋은 곳에서는 구체 타입을 쓰는 식으로 나누는 것이 일반적입니다.
auto 기본 사용법과 참조의 관계
auto라는 키워드의 내력
auto라는 단어 자체는 C 시절부터 있었습니다. 원래는 “자동 저장 기간(지역 변수)“을 뜻하는 저장 클래스 지정자였는데, 지역 변수는 어차피 기본이 자동 저장 기간이라 auto int x = 1;처럼 쓸 이유가 없었고 실제로 거의 쓰이지 않았습니다. C++11은 이 키워드를 타입 추론용으로 재활용했고, 그래서 옛 문법인 auto int x = 1;은 C++11부터 컴파일 에러입니다.
auto는 동적 타입이 아닙니다. Python 변수처럼 나중에 다른 타입의 값을 담을 수 있는 것이 아니라, 컴파일 시점에 초기화식을 보고 타입이 한 번 정해지면 그 변수는 끝까지 그 타입입니다. 런타임 비용도 없어서 auto x = 42;와 int x = 42;는 같은 코드로 컴파일됩니다. 추론할 근거가 초기화식뿐이므로 auto x;처럼 초기화 없이 선언하면 declaration of 'auto x' has no initializer 에러가 납니다.
기본 사용법
리터럴이면 42는 int, 3.14는 double, 문자열 리터럴은 const char*가 되고, std::string(...)처럼 타입이 드러난 초기화식이면 그 타입이 됩니다.
// g++ -std=c++17 -o auto_basic auto_basic.cpp && ./auto_basic
#include <iostream>
#include <string>
#include <vector>
#include <map>
int main() {
auto i = 42; // int
auto d = 3.14; // double
auto s = "hello"; // const char*
auto str = std::string("world"); // std::string
std::cout << i << " " << d << " " << s << " " << str << "\n";
auto vec = std::vector<int>{1, 2, 3};
auto m = std::map<std::string, int>{{"a", 1}};
std::cout << vec.size() << " " << m["a"] << "\n";
return 0;
}
실행하면 42 3.14 hello world 한 줄과 3 1 한 줄이 출력됩니다.
auto와 참조
auto x = expr은 함수 템플릿 template<class T> void f(T param)에 expr을 넘겼을 때 T를 추론하는 규칙과 같습니다(중괄호 초기화만 예외입니다). 그래서 초기화식의 참조와 최상위 const/volatile이 떨어져 나갑니다. 참조를 유지하려면 auto&, const auto&, auto&&를 명시해야 합니다.
#include <utility>
int main() {
int x = 42;
const int cx = 100;
int& rx = x;
const int& crx = x;
// auto: 값 타입 (참조와 최상위 const 제거)
auto a1 = x; // int
auto a2 = cx; // int
auto a3 = rx; // int
auto a4 = crx; // int
// auto&: 왼쪽값 참조 (rvalue에 바인딩 불가)
auto& r1 = x; // int&
auto& r2 = cx; // const int& (참조로 받을 때는 const가 유지됨)
auto& r3 = rx; // int&
// auto& r4 = 42; // 에러: rvalue에 non-const lvalue 참조 바인딩 불가
// const auto&: 읽기 전용 참조 (rvalue도 바인딩 가능)
const auto& cr1 = x; // const int&
const auto& cr2 = 42; // const int& (임시 int에 바인딩, 수명 연장)
// auto&&: 전달 참조
auto&& u1 = x; // int& (lvalue)
auto&& u2 = 42; // int&& (rvalue)
auto&& u3 = cx; // const int&
auto&& u4 = std::move(x); // int&&
return 0;
}
값으로 받을 때 const가 사라지는 이유는 복사본의 const 여부가 원본과 무관하기 때문입니다. 원본이 const int여도 복사해 온 int는 자유롭게 바꿀 수 있습니다. 반면 auto&로 받으면 원본을 그대로 가리키므로 원본의 const를 지켜야 하고, 그래서 r2는 const int&가 됩니다. 정리하면 읽기만 할 때는 const auto&, 수정할 때는 auto&, 다른 함수로 그대로 넘길 때는 auto&&를 씁니다.
auto&&는 템플릿의 T&&와 같은 전달 참조(forwarding reference)입니다. lvalue를 주면 T가 X&로 추론되고 참조 축약에 의해 X&가 되며, rvalue를 주면 X&&가 됩니다.
함수 반환 타입
C++14부터 함수 반환 타입을 auto로 두면 return 문의 표현식으로부터 타입이 추론됩니다. return이 여러 개라면 모두 같은 타입으로 추론되어야 합니다.
auto getNumber() {
return 42; // int
}
auto getString() {
return std::string("hello"); // std::string
}
auto createMap() {
return std::map<std::string, std::vector<int>>{};
}
auto 반환에는 분리 컴파일 제약이 있습니다. 반환 타입을 알려면 본문을 봐야 하므로, 헤더에 auto createParser();만 선언해 두고 정의를 .cpp에 두면 다른 번역 단위에서 호출할 때 “반환 타입을 추론하기 전에 사용했다”는 에러가 납니다. 공개 헤더에 두는 함수라면 반환 타입을 명시하거나, 템플릿·inline 함수처럼 본문을 헤더에 함께 두어야 합니다.
중괄호 초기화
auto x = {1, 2, 3};처럼 = 뒤에 중괄호를 주면 std::initializer_list<int>로 추론됩니다. 템플릿 인자 추론과 다른 유일한 규칙입니다. vector를 원했다면 std::vector<int>{1, 2, 3}처럼 타입을 명시해야 합니다.
auto a = {1, 2, 3}; // std::initializer_list<int>
// a.push_back(4); // 에러: initializer_list에는 push_back이 없음
auto b = {1}; // std::initializer_list<int>
auto c{1}; // int (C++17 규칙)
// auto d{1, 2}; // 에러 (C++17 규칙)
auto v = std::vector<int>{1, 2, 3};
v.push_back(4); // OK
직접 초기화 auto c{1}의 결과는 방향이 반대로 알려진 경우가 많습니다. C++11/14 원래 규칙에서는 auto c{1}도 std::initializer_list<int>였고, 표준 제안 N3922가 적용된 C++17부터 직접 초기화는 원소가 하나면 그 원소 타입(int), 둘 이상이면 컴파일 에러가 되었습니다. GCC·Clang은 이 변경을 C++11/14 모드에도 결함 수정으로 소급 적용하므로, 컴파일러 버전에 따라 결과가 달라 보일 수 있습니다. 복사 초기화 auto b = {1}은 지금도 std::initializer_list<int>입니다.
decltype: 변수와 표현식의 타입을 그대로 가져오기
기본 사용법
decltype(식)은 식의 타입을 참조와 const까지 그대로 돌려줍니다. 변수 이름을 넣으면 그 변수의 선언 타입이 나오므로 “x와 같은 타입”의 변수를 선언할 때 쓰고, 표현식을 넣으면 그 결과 타입이 나오므로 템플릿에서 “두 인자를 연산한 결과 타입”을 표현할 때 씁니다.
int x = 42;
decltype(x) y = x; // int
const int& rx = x;
decltype(rx) ry = x; // const int&
decltype(x + y) z = x + y; // int
decltype(x * 2.0) d = x * 2.0; // double
int func();
decltype(func()) result = 0; // int (func는 호출되지 않음)
변수 이름과 표현식의 차이
decltype의 결과는 괄호 없는 변수 이름이냐, 그 외 표현식이냐에 따라 달라집니다.
#include <utility>
int main() {
int x = 42;
const int cx = 100;
int& rx = x;
// 괄호 없는 변수 이름: 선언 타입 그대로
decltype(x) a = x; // int
decltype(cx) b = cx; // const int
decltype(rx) c = x; // int&
// 그 외 표현식: 값 카테고리에 따라 결정
decltype((x)) d = x; // int& ((x)는 lvalue 표현식)
decltype((cx)) e = x; // const int&
decltype(42) f = 42; // int (prvalue)
decltype(std::move(x)) g = std::move(x); // int&& (xvalue)
decltype(x++) h = x; // int (x++는 prvalue)
decltype(++x) i = x; // int& (++x는 lvalue)
return 0;
}
규칙을 표준 문구에 가깝게 줄이면 두 갈래입니다. 괄호 없는 변수 이름이나 멤버 접근(x, obj.member, p->member)이면 선언된 타입을 그대로 돌려주고, 그 외 모든 표현식은 값 카테고리로 정해집니다. lvalue면 T&, xvalue(std::move(x))면 T&&, prvalue(x + 1)면 T입니다. *ptr, vec[0], ++x가 참조로 나오는 것도 같은 이유입니다. 그래서 decltype((x))처럼 변수를 괄호로 감싸기만 해도 lvalue 표현식으로 취급되어 int&가 됩니다.
decltype의 괄호 안은 sizeof처럼 평가되지 않는 피연산자입니다. decltype(x++)는 x를 증가시키지 않고, decltype(func())는 func를 실제로 호출하지 않으므로 정의 없이 선언만 있는 함수도 쓸 수 있습니다. 다만 표현식이 타입 검사는 통과해야 하므로 그 과정에서 인스턴스화되는 템플릿의 static_assert는 실패할 수 있습니다.
람다 안에서도 차이가 드러납니다. 값으로 캡처한 x에 대해 decltype(x)는 바깥 변수의 선언 타입이지만, decltype((x))는 클로저 멤버 접근으로 취급되어 mutable이 아닌 람다에서는 const int&가 됩니다.
조건 연산자와 declval의 함정
조건 연산자는 이 규칙 때문에 위험한 결과를 냅니다. template<class T, class U> auto max_value(T a, U b) -> decltype(a > b ? a : b)는 T와 U가 다르면 공통 타입으로 변환된 prvalue라 double 같은 값 타입이 나오지만, 두 타입이 같으면 a > b ? a : b가 lvalue가 되어 int&가 나오고, 값으로 받은 매개변수에 대한 참조를 반환하는 댕글링이 됩니다. 타입이 같을 때만 생기는 버그라 테스트에서 놓치기 쉬우므로, 값을 돌려주려면 -> std::common_type_t<T, U>나 그냥 auto 반환을 씁니다.
템플릿에서 객체 없이 표현식의 타입을 검사하는 std::declval<T>()는 결과가 rvalue(T&&)라서 & 한정 멤버 함수(void foo() &;)는 호출할 수 없는 것으로 판정됩니다. lvalue로 검사하려면 std::declval<T&>()를 써야 합니다. 또 decltype 기반 SFINAE는 즉시 문맥(immediate context)의 실패만 후보 탈락으로 처리하므로, decltype 안에서 호출한 함수 템플릿의 본문에서 난 에러는 탈락이 아니라 하드 에러가 됩니다.
decltype(auto)
초기화식의 타입을 그대로 보존
auto는 참조와 const를 제거하지만, decltype(auto)는 초기화식에 decltype 규칙을 적용하므로 타입을 그대로 보존합니다.
int x = 42;
const int& rx = x;
auto a = rx; // int (const와 & 제거)
decltype(auto) da = rx; // const int&
decltype(auto) d1 = x; // int (변수 이름)
decltype(auto) d2 = (x); // int& (괄호로 lvalue 표현식)
아래 표는 선언 형태별로 추론 결과를 비교한 것입니다. 왼쪽 열은 const int& cx = x;로 선언된 변수 cx를, 오른쪽 열은 리터럴 42(prvalue)를 초기화식으로 준 경우입니다.
| 선언 형태 | 초기화식 cx | 초기화식 42 |
|---|---|---|
auto a | int | int |
auto& r | const int& | 에러 (rvalue에 바인딩 불가) |
const auto& cr | const int& | const int& (임시 객체, 수명 연장) |
auto&& u | const int& | int&& |
decltype(auto) d | const int& | int |
주의할 점은 int&& y = 42;처럼 이름이 붙은 rvalue 참조 변수 y는 표현식으로서는 lvalue라는 것입니다. 그래서 auto& r = y;는 int&로 문제없이 바인딩되고, auto&& u = y;도 int&&가 아니라 int&가 됩니다. decltype(auto) d = y;만 선언 타입을 따라 int&&가 됩니다.
참조 반환 보존
decltype(auto)의 핵심 용도는 참조 반환을 그대로 전달하는 것입니다. auto 반환은 값 추론 규칙을 쓰므로 참조를 반환하지 않습니다. 컨테이너 원소를 돌려주는 래퍼를 auto get(std::vector<int>& v, size_t i) { return v[i]; }로 쓰면 복사본(prvalue)이 반환되고, get(v, 0) = 5;는 원본을 바꾸지 못하는 정도가 아니라 컴파일 에러가 납니다(prvalue인 int에는 대입할 수 없습니다).
#include <iostream>
#include <map>
#include <string>
#include <vector>
// vector::operator[]는 T&를 반환하므로 decltype(auto)는 T&
template <typename T>
decltype(auto) getElement(std::vector<T>& v, size_t i) {
return v[i];
}
// 멤버 접근은 괄호가 없으면 "선언 타입"(mapped_type)으로 추론되어 복사가 됩니다.
// 참조를 돌려주려면 괄호로 감싸 lvalue 표현식으로 만들어야 합니다.
template <typename Map>
decltype(auto) getOrCreate(Map& m, const typename Map::key_type& key,
const typename Map::mapped_type& defaultVal) {
return (m.try_emplace(key, defaultVal).first->second); // mapped_type&
}
int main() {
std::vector<int> vec = {1, 2, 3};
getElement(vec, 0) = 10; // vec[0] = 10
std::cout << vec[0] << "\n"; // 10
std::map<std::string, int> scores;
getOrCreate(scores, "Alice", 0) = 95; // scores["Alice"] = 95
std::cout << scores["Alice"] << "\n"; // 95
}
getOrCreate의 return 문에서 괄호를 빼면 it->second 형태의 멤버 접근이 되어 decltype이 멤버의 선언 타입 int를 돌려주고, 함수는 복사본을 반환합니다. 그러면 = 95 대입은 컴파일 에러가 납니다. 같은 이유로 매개변수를 const K& key처럼 별도 템플릿 인자로 받으면 "Alice"에서 K가 const char[6]로 추론되어 맵의 키 타입과 충돌하므로, 여기서는 Map::key_type으로 받아 추론 대상에서 제외했습니다.
괄호와 댕글링
decltype(auto) 반환에서 return (x);처럼 지역 변수나 값 매개변수를 괄호로 감싸면 T&가 되어 곧 사라질 객체의 참조를 반환합니다.
#include <utility>
// 위험: 지역 변수 x에 대한 int& 반환
decltype(auto) bad() { int x = 42; return (x); }
// 위험: 값 매개변수 t는 항상 함수 안의 복사본이므로 (t)는 언제나 댕글링 참조
template <typename T>
decltype(auto) alsoBad(T t) { return (t); }
// lvalue를 넘기면 T&, rvalue를 넘기면 T&&를 그대로 돌려줌
template <typename T>
decltype(auto) passThrough(T&& t) { return std::forward<T>(t); }
passThrough도 rvalue를 넘긴 경우에는 T&&를 돌려주므로, auto&& r = passThrough(std::string("x"));처럼 결과를 참조로 붙잡으면 문장이 끝날 때 임시 객체가 사라져 댕글링이 됩니다. 반환된 참조는 같은 문장 안에서 바로 쓰거나 값으로 받아 두는 편이 안전합니다.
완벽한 전달 래퍼
함수와 인자를 그대로 다른 함수에 넘기는 래퍼에서는 인자의 값 종류와 반환 타입을 모두 보존해야 합니다. 인자는 std::forward로, 반환 타입은 decltype(auto)로 보존합니다.
#include <functional>
#include <utility>
template <typename Func, typename... Args>
decltype(auto) callFunction(Func&& func, Args&&... args) {
return std::invoke(std::forward<Func>(func), std::forward<Args>(args)...);
}
std::invoke를 쓰면 일반 함수, 함수 객체뿐 아니라 멤버 함수 포인터도 같은 방식으로 호출할 수 있습니다. 호출 대상이 참조를 반환하면 래퍼도 참조를 반환하고, 값을 반환하면 값을 반환합니다.
멤버 접근자도 같은 패턴으로 만들 수 있습니다.
#include <string>
#include <utility>
#include <vector>
class Matrix {
std::vector<double> data_;
public:
explicit Matrix(size_t n) : data_(n) {}
decltype(auto) operator[](size_t i) { return data_[i]; } // double&
decltype(auto) operator[](size_t i) const { return data_[i]; } // const double&
};
template <typename T>
class Wrapper {
T value_;
public:
explicit Wrapper(T v) : value_(std::move(v)) {}
decltype(auto) value() & { return (value_); } // T&
decltype(auto) value() const& { return (value_); } // const T&
decltype(auto) value() && { return std::move(value_); } // T&&
};
void test() {
Matrix matrix(4);
decltype(auto) elem = matrix[0]; // double&
elem = 42.0; // 원본 수정
Wrapper<std::string> wrapper{"hello"};
decltype(auto) str = wrapper.value(); // std::string&
str += " world"; // 원본 수정
}
Wrapper::value()의 return 문에 괄호가 있는 이유도 앞의 getOrCreate와 같습니다. return value_;라고 쓰면 멤버 이름 그대로라 선언 타입 T가 되어 복사본을 반환합니다.
후행 반환 타입과의 관계
C++11에는 decltype(auto)도 auto 반환 추론도 없었으므로, 반환 타입이 매개변수에 의존하면 후행 반환 타입으로 적어야 했습니다.
// C++11: 후행 반환 타입
template <typename T, typename U>
auto add(T t, U u) -> decltype(t + u) {
return t + u;
}
// C++14: 반환 타입 추론
template <typename T, typename U>
auto add14(T t, U u) {
return t + u;
}
반환 타입을 뒤로 미뤄야 했던 이유는 선언 순서에 있습니다. decltype(t + u) add(T t, U u)처럼 앞에 쓰면, 컴파일러가 반환 타입을 읽는 시점에는 t와 u가 아직 선언되지 않았으므로 이름을 찾을 수 없습니다. 후행 반환 타입은 매개변수 목록 다음에 오기 때문에 매개변수 이름을 쓸 수 있습니다.
후행 반환 타입은 C++14 이후에도 SFINAE 용도로 쓰입니다. auto begin(C& c) -> decltype(c.begin())처럼 쓰면 c.begin()이 유효하지 않은 타입에서 이 오버로드가 조용히 후보에서 빠지므로, C 배열용 오버로드 template <typename T, size_t N> T* begin(T (&arr)[N])와 함께 둘 수 있습니다. 본문에서 추론하는 auto 반환은 선언만으로 타입을 알 수 없어서 이런 후보 제외가 일어나지 않습니다.
반대로 선언 시점에 타입을 계산하면 안 되는 경우도 있습니다. CRTP에서 Base<Derived>의 멤버를 auto value() -> decltype(std::declval<Derived>().compute())로 선언하면, Derived가 Base<Derived>를 상속하는 시점에 Base<Derived>가 인스턴스화되면서 멤버 함수 선언의 반환 타입도 계산되는데, 이때 Derived는 아직 불완전한 타입이라 컴파일 에러가 납니다. 본문에서 추론하는 decltype(auto)는 함수가 실제로 쓰일 때 인스턴스화되므로 이 문제가 없습니다.
template <typename Derived>
struct Base {
decltype(auto) value() {
return static_cast<Derived*>(this)->compute();
}
};
struct Impl : Base<Impl> {
int compute() { return 42; }
};
범위 for, 람다, 알고리즘에서 auto 쓰기
범위 기반 for
범위 기반 for에서 auto item은 원소를 매번 복사하므로, pair나 string처럼 복사 비용이 있는 타입이면 낭비이고, 수정해도 원본에는 반영되지 않습니다.
std::map<std::string, int> scores = {{"Alice", 95}, {"Bob", 87}, {"Charlie", 92}};
for (auto item : scores) { /* pair<const string, int> 복사 */ }
for (const auto& item : scores) { /* 복사 없음 */ }
for (const auto& [name, score] : scores) { // C++17 구조화된 바인딩
std::cout << name << ": " << score << "\n";
}
std::vector<std::string> names = {"Alice", "Bob"};
for (auto name : names) { name += "!"; } // 복사본만 수정, 원본은 그대로
for (auto& name : names) { name += "!"; } // 원본 수정
auto가 직접 쓴 타입보다 정확한 경우도 있습니다. std::map<std::string, int>의 원소 타입은 std::pair<const std::string, int>입니다. 키의 const를 빼먹고 for (const std::pair<std::string, int>& p : scores)처럼 쓰면 타입이 정확히 맞지 않아, 컴파일러가 반복마다 임시 pair를 만들어 참조에 묶습니다. 컴파일도 되고 결과도 맞지만, 참조로 받는다고 생각했던 것이 사실은 매번 복사입니다. const auto&는 정확한 원소 타입을 추론하므로 이런 실수가 생기지 않습니다.
람다와 알고리즘
#include <algorithm>
#include <iostream>
#include <vector>
// C++14: 람다를 반환하는 함수. C++11에서는 반환 타입에 람다 타입을 쓸 수 없어 std::function으로 감싸야 했습니다.
auto makeAdder(int x) {
return [x](int y) { return x + y; };
}
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
auto isEven = [](int x) { return x % 2 == 0; };
auto count = std::count_if(numbers.begin(), numbers.end(), isEven); // difference_type
std::cout << "짝수: " << count << "\n"; // 짝수: 5
// 반환문이 여러 개인 람다: 모든 return의 타입이 같아야 추론됩니다
auto transform = [](int x) {
if (x % 2 == 0) return x * 2;
return x * 3; // 둘 다 int라서 OK. 한쪽이 double이면 컴파일 에러
};
for (auto& num : numbers) num = transform(num);
auto maxIt = std::max_element(numbers.begin(), numbers.end());
if (maxIt != numbers.end()) std::cout << "Max: " << *maxIt << "\n"; // Max: 27
auto add5 = makeAdder(5);
std::cout << add5(10) << "\n"; // 15
}
std::count_if의 반환 타입은 int가 아니라 반복자의 difference_type(벡터에서는 std::ptrdiff_t)입니다. int count = ...로 받으면 조용히 좁히는 변환이 일어나는데, auto로 받으면 그런 일이 없습니다. 반환문이 여러 개인 람다는 모든 return 식의 타입이 같아야 하며, 섞이면 -> double처럼 반환 타입을 명시해야 합니다.
auto와 std::function의 차이는 비용에 있습니다. auto는 클로저 객체를 그대로 담으므로 호출이 인라인될 수 있고 추가 비용이 없습니다. std::function은 타입 소거(type erasure)를 위해 간접 호출을 거치고, 캡처가 커서 내부 버퍼(small buffer)에 들어가지 않으면 힙 할당도 생깁니다. 서로 다른 람다를 같은 컨테이너에 담거나 클래스 멤버·콜백으로 저장해야 할 때만 std::function을 쓰고, 지역 변수라면 auto가 맞습니다.
auto를 쓸 때 자주 하는 실수
리터럴의 타입
auto는 리터럴 타입을 그대로 따르므로, 원하는 크기가 있다면 접미사를 붙여야 합니다.
auto x = 0; // int (long long 아님)
auto y = 0.0f; // float (double 아님)
auto big = 0LL; // long long
auto z = 0.0; // double
std::vector<int> vec(10);
for (auto i = 0; i < vec.size(); ++i) {} // i는 int: signed/unsigned 비교 경고(-Wsign-compare)
for (auto i = size_t{0}; i < vec.size(); ++i) {} // size_t
// for (auto i = 0uz; i < vec.size(); ++i) {} // C++23: size_t 리터럴 접미사
프록시 타입: vector<bool>
std::vector<bool>의 operator[]는 bool&가 아니라 비트 하나를 가리키는 프록시 객체 std::vector<bool>::reference를 반환합니다. auto는 이 프록시 타입을 그대로 추론합니다.
std::vector<bool> flags(100);
bool copy = flags[0]; // 명시적 bool: 값이 복사됨
auto copy2 = static_cast<bool>(flags[0]); // auto를 쓰되 캐스트로 의도를 드러냄 (Effective Modern C++ 항목 6)
auto flag = flags[0]; // bool이 아니라 std::vector<bool>::reference
flags.clear(); // 원소에 대한 모든 참조가 무효화됨
// 이후 flag를 읽거나 쓰면 미정의 동작 (copy, copy2는 안전)
프록시는 원본 비트를 가리키므로, flag가 살아 있는 동안 flags가 바뀌면 flag가 읽는 값도 바뀌고, clear나 재할당으로 저장 공간이 무효화되면 댕글링이 됩니다. decltype(auto)도 같은 프록시 타입을 추론하므로 해결책이 되지 않습니다. 표현식 템플릿을 쓰는 수치 라이브러리(Eigen 등)에서도 auto로 받으면 계산 결과가 아니라 지연 계산 객체가 담기는 같은 문제가 생깁니다.
임시 객체에서 꺼낸 참조
// 안전: 임시 객체에 직접 바인딩하면 수명이 참조 변수만큼 연장됨
auto&& str = getString(); // std::string&&
// 위험: 임시 객체의 멤버나 원소에 대한 참조를 돌려받는 경우
auto&& first = getVector()[0]; // operator[]가 반환한 참조에 바인딩
// 임시 vector는 이 문장이 끝나면 소멸 → first는 댕글링
// 해결: 값으로 받기
auto firstValue = getVector()[0];
수명 연장은 임시 객체에 직접 참조를 묶을 때만 일어납니다. 함수 호출을 거쳐 돌아온 참조(operator[], getter)에는 적용되지 않으므로, 임시 객체에서 무언가를 꺼내 참조로 붙잡는 코드는 auto&&든 const auto&든 똑같이 위험합니다.
반환 타입이 다른 return 문
// 컴파일 에러: int와 std::string 타입 불일치
// auto getValue(bool flag) {
// if (flag) return 42;
// return std::string("hello");
// }
// 공통 타입을 명시
std::variant<int, std::string> getValue(bool flag) {
if (flag) return 42;
return std::string("hello");
}
AAA 스타일과 팀 기준
AAA(Almost Always Auto)는 Herb Sutter가 제안한 관례로, 가능한 곳에서는 auto를 쓰고 타입을 지정하고 싶으면 auto x = Type{...}처럼 오른쪽에 한 번만 적는 스타일입니다. 이렇게 하면 초기화가 강제되고(auto x;는 컴파일되지 않습니다), int n = vec.size();처럼 size_t를 int로 조용히 줄이는 변환이 생기지 않으며, 반환 타입이 바뀌어도 받는 쪽 선언을 고칠 필요가 없습니다. 타입은 컴파일 시점에 정해지므로 생성되는 코드는 명시적 타입과 같습니다.
대가는 가독성입니다. auto x = func();만 보면 x의 타입을 알 수 없고, IDE가 보여 주더라도 코드 리뷰 도구나 diff에서는 보이지 않습니다. 그래서 많은 팀이 다음처럼 절충합니다.
| 상황 | 권장 | 이유 |
|---|---|---|
| 반복자, 알고리즘 결과 | auto | 타입이 길고 컨테이너 변경에 따라 바뀜 |
| 람다 저장 | auto | 람다 타입은 이름을 쓸 수 없음 |
| 범위 기반 for (읽기 / 수정) | const auto& / auto& | 복사 방지, 수정 의도 표현 |
| 참조 반환 래퍼 | decltype(auto) | 참조 보존 |
| 공개 API의 반환 타입 | 명시적 타입 | 선언만 보고 계약을 알 수 있어야 함 |
vector<bool> 등 프록시 반환 | 명시적 타입 | 프록시가 담기는 것을 방지 |
using UserId = int64_t; 같은 타입 별칭은 긴 타입에 의미 있는 이름을 붙여 줄 뿐, 새 타입을 만들지는 않는다는 점도 기억해 둘 만합니다. UserId와 OrderId가 둘 다 int64_t의 별칭이라면 서로 바꿔 넘겨도 컴파일러가 막지 못하므로, 실제로 섞이면 안 되는 도메인 값이라면 struct UserId { int64_t value; };처럼 별도 타입으로 감싸야 합니다.
정적 분석 도구로 일부 규칙을 자동으로 점검할 수 있습니다. Clang-Tidy의 modernize-use-auto는 캐스트, new, 반복자처럼 타입이 오른쪽에 이미 드러나는 곳에 auto를 제안하고, readability-qualified-auto는 포인터를 auto로 받을 때 auto*·const auto*로 드러내도록 제안하며, performance-for-range-copy는 범위 기반 for에서 불필요한 복사를 찾아 줍니다.
참고 자료
같이 보면 좋은 글
- C++ 범위 기반 for문과 구조화된 바인딩 | 모던 C++ 반복문
- C++ 템플릿 기초: 함수·클래스 템플릿, 가변 인자, SFINAE에서 Concepts까지
- C++ 람다 표현식: 캡처·mutable·제네릭 람다부터 deducing this 재귀까지
- C++ 참조: lvalue·rvalue·const 참조, 참조 vs 포인터, 참조 축약 규칙
- C++17 optional·variant·any: nullptr 체크 대신 타입으로 표현하기
- C++ auto 타입 추론 실수: 참조·const가 떨어지는 복사, vector<bool> 프록시, 중괄호 추론