auto 키워드와 타입 추론
초기화 식에서 타입을 추론하는 auto의 decay·const·참조 규칙을 익히고 명시적 타입과의 가독성 균형을 판단합니다.
지금까지 C++의 기본 문법, 객체 지향, 템플릿, STL, 예외 처리를 다뤘습니다.
이번 장부터는 C++11 이후 도입되어 C++ 코드를 더 간결하고 명확하게 작성하게 해주는 현대 C++ 기능들을 다룹니다.
이 기능들은 코딩 스타일과 생산성에 큰 변화를 가져왔습니다.
이번 장에서는 현대 C++의 대표 기능 중 하나인 auto 키워드와 타입 추론(Type Deduction)을 다룹니다.
auto는 코드의 가독성을 높이고, 복잡한 타입 이름을 직접 명시할 필요를 없애주어 개발 생산성을 크게 향상시킵니다.
먼저 auto가 “타입을 숨기는 문법”이 아니라 초기화 식과 선언 형태를 함께 읽는 타입 추론 규칙이라는 점을 확인합니다.
auto 키워드의 등장 배경
과거 C++98/03에서는 변수를 선언할 때 항상 해당 변수의 타입을 명시해야 했습니다.
이는 때로는 매우 길고 복잡한 타입 이름을 작성해야 하는 불편함을 초래했습니다.
std::vector<std::pair<std::string, int> > my_map;
// ...
std::vector<std::pair<std::string, int> >::iterator it = my_map.begin(); // 매우 김!이러한 문제점을 해결하기 위해 C++11에서 auto 키워드가 재도입되었습니다. (과거 C++98에서도 auto는 있었지만, 저장 기간 지정자로 사용되었고 거의 쓰이지 않아 C++11에서 의미가 변경되었습니다.)
auto 키워드를 사용하면 컴파일러가 초기화 식을 분석하여 변수의 타입을 자동으로 추론합니다.
개발자는 명시적으로 타입을 작성할 필요가 없어집니다.
auto 키워드의 기본 사용법
auto는 변수 선언 시 사용하며, 반드시 초기화 식과 함께 사용되어야 합니다.
초기화 식을 통해 컴파일러가 타입을 추론하기 때문입니다.
auto 변수이름 = 초기화_식;#include <typeinfo>
#include <iostream>
#include <string>
#include <vector>
int main() {
auto i = 10; // int로 추론됨
auto d = 3.14; // double로 추론됨
auto s = "Hello"; // const char*로 추론됨
auto name = std::string("Alice"); // std::string으로 추론됨
auto b = true; // bool로 추론됨
std::cout << "i: " << i << ", type: " << typeid(i).name() << std::endl;
std::cout << "d: " << d << ", type: " << typeid(d).name() << std::endl;
std::cout << "s: " << s << ", type: " << typeid(s).name() << std::endl;
std::cout << "name: " << name << ", type: " << typeid(name).name() << std::endl;
std::cout << "b: " << b << ", type: " << typeid(b).name() << std::endl;
std::vector<int> numbers = {1, 2, 3};
auto it = numbers.begin(); // std::vector<int>::iterator 로 추론됨
std::cout << "*it: " << *it << ", type: " << typeid(it).name() << std::endl;
auto& ref_i = i; // int& (참조)로 추론됨
std::cout << "ref_i: " << ref_i << ", type: " << typeid(ref_i).name() << std::endl;
const auto const_i = 20; // const int 로 추론됨
std::cout << "const_i: " << const_i << ", type: " << typeid(const_i).name() << std::endl;
return 0;
}typeid(변수).name()은 타입 정보를 문자열로 확인하는 데 사용할 수 있지만 최상위 const와 참조 차이는 이 결과로 구별할 수 없습니다. 정확한 선언 타입 검사는 decltype과 타입 특성을 사용합니다.
컴파일러에 따라 출력되는 이름이 다를 수 있습니다 (예: int는 i, double은 d, std::string은 Ss 등으로 축약될 수 있음).
중요한 것은 컴파일러가 적절한 타입을 추론한다는 것입니다.
auto 타입 추론 규칙 (Decay Rule)
다음 그림은 auto, auto&, const auto&처럼 선언 형태가 달라질 때 값 복사, 참조 유지, const 보존, 배열/함수 decay가 어떻게 달라지는지 정리합니다.
auto는 initializer와 declarator를 함께 읽어 타입을 추론한다
그림이 source=20/copied=99인 최종 상태인데 본문 정수 실험은 copied와 alias 변경을 순서대로 고정한다. 선언→복사본 변경→원본 변경으로 차이를 드러낼 수 있다.
C++ · TYPE DEDUCTION
auto는 초기화 식과 선언 형태를 함께 읽는다
같은 원본도 값·참조·중괄호 초기화에 따라 다른 타입을 만든다. 각 예제의 선언 타입과 원본 수명을 함께 확인한다.
그림은 본문 정수 실험의 최종 상태이다. copied는 별도 정수, alias는 같은 source를 본다. 배열에서 추론한 포인터는 배열을 복제하지 않는다.
값 선언 · auto
값으로 선언하면 최상위 const와 참조를 유지하지 않는다.
const int cx = 100;
auto a = cx; // int포인터가 가리키는 대상의 const까지 없애는 규칙은 아니다.
참조 선언 · auto& / const auto&
원본을 참조하며 원본의 const 또는 선언한 const를 유지한다.
auto& r = cx; // const int&
int x = 10;
const auto& cr = x;참조를 사용하는 동안 대상 수명이 살아 있어야 한다.
배열 · 값과 참조의 차이
값 선언은 배열이 포인터로 변환되고, 참조 선언은 배열 타입을 유지한다.
int arr[] = {1, 2, 3};
auto p = arr; // int*
auto& a = arr; // int (&)[3]함수도 값 선언에서 함수 포인터로 변환될 수 있다.
copy-list · = { ... }
중괄호 원소에서 같은 원소 타입을 추론해야 한다.
auto list = {1, 2, 3};
// initializer_list<int>{1, 2.0}처럼 추론한 원소 타입이 다르면 실패한다.
direct-list · {one}
현대 규칙에서 단일 원소 타입을 추론한다. 여러 원소는 허용하지 않는다.
auto n{1}; // int
// auto bad{1, 2}; // 오류copy-list의 = 유무가 추론 결과를 바꾼다.
값 범주 · auto&&
일반 표현식 초기화에서는 lvalue를 참조 축약하고 rvalue에 rvalue 참조를 만든다.
auto&& l = x; // int&
auto&& r = 100; // int&&r처럼 이름 있는 참조 변수 표현식 자체는 lvalue이다.
typeid().name()은 최상위 const·참조 차이를 구별하지 못한다. decltype과 타입 특성으로 정확한 선언 타입을 확인한다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | source를 복사한 copied는 별도 정수이고 alias는 같은 source를 참조한다. source 최종 20, copied 최종 99. 배열 포인터 추론과 별도로 정수 사례를 보여 준다. |
| 본문·주석 | 참조를 사용하는 동안 대상이 살아 있어야 한다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 값·참조 준비 copied는 사본이며 alias는 source의 별명이다. |
|
|
| 사본 변경 copied를 99로 바꾼다. |
|
|
| 참조 변경 alias를 통해 source를 20으로 바꾼다. |
|
|
auto는 initializer와 declarator를 함께 읽어 타입을 추론한다
그림이 source=20/copied=99인 최종 상태인데 본문 정수 실험은 copied와 alias 변경을 순서대로 고정한다. 선언→복사본 변경→원본 변경으로 차이를 드러낼 수 있다.
C++ · TYPE DEDUCTION
auto는 초기화 식과 선언 형태를 함께 읽는다
같은 원본도 값·참조·중괄호 초기화에 따라 다른 타입을 만든다. 각 예제의 선언 타입과 원본 수명을 함께 확인한다.
그림은 본문 정수 실험의 최종 상태이다. copied는 별도 정수, alias는 같은 source를 본다. 배열에서 추론한 포인터는 배열을 복제하지 않는다.
값 선언 · auto
값으로 선언하면 최상위 const와 참조를 유지하지 않는다.
const int cx = 100;
auto a = cx; // int포인터가 가리키는 대상의 const까지 없애는 규칙은 아니다.
참조 선언 · auto& / const auto&
원본을 참조하며 원본의 const 또는 선언한 const를 유지한다.
auto& r = cx; // const int&
int x = 10;
const auto& cr = x;참조를 사용하는 동안 대상 수명이 살아 있어야 한다.
배열 · 값과 참조의 차이
값 선언은 배열이 포인터로 변환되고, 참조 선언은 배열 타입을 유지한다.
int arr[] = {1, 2, 3};
auto p = arr; // int*
auto& a = arr; // int (&)[3]함수도 값 선언에서 함수 포인터로 변환될 수 있다.
copy-list · = { ... }
중괄호 원소에서 같은 원소 타입을 추론해야 한다.
auto list = {1, 2, 3};
// initializer_list<int>{1, 2.0}처럼 추론한 원소 타입이 다르면 실패한다.
direct-list · {one}
현대 규칙에서 단일 원소 타입을 추론한다. 여러 원소는 허용하지 않는다.
auto n{1}; // int
// auto bad{1, 2}; // 오류copy-list의 = 유무가 추론 결과를 바꾼다.
값 범주 · auto&&
일반 표현식 초기화에서는 lvalue를 참조 축약하고 rvalue에 rvalue 참조를 만든다.
auto&& l = x; // int&
auto&& r = 100; // int&&r처럼 이름 있는 참조 변수 표현식 자체는 lvalue이다.
typeid().name()은 최상위 const·참조 차이를 구별하지 못한다. decltype과 타입 특성으로 정확한 선언 타입을 확인한다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | source를 복사한 copied는 별도 정수이고 alias는 같은 source를 참조한다. source 최종 20, copied 최종 99. 배열 포인터 추론과 별도로 정수 사례를 보여 준다. |
| 본문·주석 | 참조를 사용하는 동안 대상이 살아 있어야 한다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 값·참조 준비 copied는 사본이며 alias는 source의 별명이다. |
|
|
| 사본 변경 copied를 99로 바꾼다. |
|
|
| 참조 변경 alias를 통해 source를 20으로 바꾼다. |
|
|
auto는 초기화 식으로부터 타입을 추론할 때, 배열이나 함수는 포인터로 붕괴(decay)되고, const나 volatile 같은 최상위 CV(Const/Volatile) 한정자는 값 선언에서 제거됩니다. 포인터가 가리키는 대상의 const까지 제거하지는 않습니다.
또한 참조는 참조되는 타입으로 붕괴됩니다.
이는 함수 인자 전달 시의 타입 추론 규칙과 유사합니다.
#include <iostream>
#include <vector>
#include <typeinfo> // typeid를 위해
int main() {
int arr[] = {1, 2, 3};
auto a1 = arr; // int* 로 추론됨 (배열은 포인터로 붕괴)
std::cout << "a1 type: " << typeid(a1).name() << std::endl;
const int c_val = 100;
auto a2 = c_val; // int 로 추론됨 (const 한정자 제거)
std::cout << "a2 type: " << typeid(a2).name() << std::endl;
int& ref_val = arr[0];
auto a3 = ref_val; // int 로 추론됨 (참조는 참조되는 타입으로 붕괴)
std::cout << "a3 type: " << typeid(a3).name() << std::endl;
// 만약 const나 참조를 유지하고 싶다면, 명시적으로 auto& 또는 const auto&를 사용해야 합니다.
const int c_val_2 = 200;
const auto& a4 = c_val_2; // const int& 로 추론됨 (const와 참조 유지)
std::cout << "a4 type: " << typeid(a4).name() << std::endl;
return 0;
}auto&: 참조를 유지합니다.const가 원본에 있다면const도 유지됩니다.int x = 10; auto& ref_x = x;//int&const int cx = 20; auto& ref_cx = cx;//const int&
const auto&:const참조를 유지합니다.int x = 10; const auto& cref_x = x;//const int&
auto&&(Universal Reference / Forwarding Reference): C++11의 rvalue 참조와 결합되어 완벽 전달(Perfect Forwarding)에 사용됩니다. 초기화 식의 값 범주(lvalue/rvalue)에 따라lvalue reference또는rvalue reference로 추론됩니다. (고급 개념)int x = 10; auto&& val_x = x;//int&auto&& val_r = 100;//int&&
auto의 활용 사례
auto는 코드의 가독성을 높이고 반복적인 타입 선언을 줄여주므로, 현대 C++에서 매우 광범위하게 사용됩니다.
STL 반복자 선언: 가장 흔하게 사용되는 곳입니다.
복잡한 반복자 타입을 매번 작성할 필요가 없어집니다.
#include <string>
#include <vector>
#include <map>
#include <iostream>
int main() {
std::vector<int> numbers = {10, 20, 30, 40, 50};
for (auto it = numbers.begin(); it != numbers.end(); ++it) {
std::cout << *it << " ";
}
std::cout << std::endl;
std::map<std::string, int> scores = {{"Alice", 90}, {"Bob", 85}};
for (auto it = scores.begin(); it != scores.end(); ++it) {
std::cout << it->first << ": " << it->second << std::endl;
}
// C++17 이후 구조적 바인딩과 함께 더욱 간결하게 사용 가능
for (const auto& [name, score] : scores) { // for (auto& [name, score] : scores) {
std::cout << name << " -> " << score << std::endl;
}
return 0;
}람다 표현식 타입: 람다 표현식은 고유한 익명 타입을 가지므로, 람다의 고유 타입을 그대로 변수에 저장할 때 auto가 편리합니다. std::function 등으로 저장하는 방법도 있습니다.
#include <iostream>
#include <functional> // std::function을 위해 (타입 강제시)
int main() {
auto add = [](int a, int b) { return a + b; };
std::cout << "Sum: " << add(5, 3) << std::endl; // 출력: 8
// std::function으로 타입을 명시할 수도 있지만, auto가 더 간결합니다.
// std::function<int(int, int)> sub = [](int a, int b) { return a - b; };
// std::cout << "Difference: " << sub(5, 3) << std::endl; // 출력: 2
return 0;
}복잡한 함수 반환 값: 반환 타입이 길거나 명확하지 않을 때 함수 반환 값을 저장하는 데 유용합니다.
#include <iostream>
#include <string>
#include <utility>
#include <vector>
// 함수 반환 타입 추론 (C++14)
auto create_complex_object() {
return std::make_pair(std::string("key"), std::vector<double>{1.1, 2.2});
}
int main() {
auto complex_obj = create_complex_object();
std::cout << "Complex object: " << complex_obj.first << ", " << complex_obj.second[0] << std::endl;
return 0;
}앞의 예시를 실제 선택 기준으로 바꾸면, auto를 써도 되는 경우와 참조 형태를 명시해야 하는 경우를 구분할 수 있습니다.
auto 사용 시 고려사항 및 주의점
auto는 매우 유용하지만, 무분별하게 사용하면 오히려 코드의 가독성을 해치거나 잠재적인 오류를 유발할 수 있습니다.
- 타입이 간단하고 명확한 경우에는
auto대신 명시적인 타입을 사용하는 것이 가독성을 높일 수 있습니다.명확한 타입 사용 예시 int count = 0; // auto count = 0; 보다 명확 double price = 19.99; // auto price = 19.99; 보다 명확 - 특히 숫자 리터럴의 경우,
int인지long인지float인지double인지 혼동을 줄 수 있습니다.auto val = 1;//intauto val = 1.0;//doubleauto val = 1.0f;//floatauto val = 1LL;//long long
타입 붕괴(Decay) 이해: auto가 const, 참조, 배열 등을 어떻게 처리하는지 정확히 이해해야 합니다.
의도치 않게 복사본이 생성되거나 const 한정자가 제거될 수 있습니다.
필요하다면 auto&, const auto&를 사용해야 합니다.
초기화 필수: auto 변수는 반드시 초기화되어야 합니다.
그렇지 않으면 컴파일 오류가 발생합니다.
// auto x; // 오류! 초기화되지 않음
// auto y = {1, 2, 3}; // copy-list: std::initializer_list<int> (C++11 이후)
// auto z{1}; // 현대 규칙의 direct-list: int
// auto bad{1, 2}; // direct-list에 여러 원소: 오류성능 영향 없음: auto는 컴파일 시점에 타입을 결정하므로, 타입 추론 자체의 런타임 비용은 없습니다. 다만 auto와 auto& 중 무엇을 쓰느냐에 따라 복사나 참조가 선택되어 실행 비용이 달라질 수 있습니다.
이는 단순한 문법적 편의 기능입니다.
마지막으로 auto가 초기화 식의 작은 차이를 그대로 따라간다는 점을, 리터럴·중괄호 초기화·참조 유지 여부별로 다시 점검합니다.
따라 하기: 타입과 원본의 변화를 따로 확인하기
아래 파일을 auto_state.cpp로 저장하고 C++17 이상으로 실행합니다. static_assert는 선언 타입이 예상과 다르면 컴파일을 멈춥니다. 실행 출력은 복사본과 참조가 실제로 어느 값을 바꾸는지 확인합니다.
#include <initializer_list>
#include <iostream>
#include <type_traits>
int main() {
int source = 10;
auto copied = source;
auto& alias = source;
const int fixed = 30;
auto& readOnly = fixed;
const int* const originalPointer = &fixed;
auto pointerCopy = originalPointer;
int arr[] = {1, 2, 3};
auto pointer = arr;
auto& wholeArray = arr;
auto one{1};
auto list = {1};
static_assert(std::is_same_v<decltype(copied), int>);
static_assert(std::is_same_v<decltype(alias), int&>);
static_assert(std::is_same_v<decltype(readOnly), const int&>);
static_assert(std::is_same_v<decltype(pointerCopy), const int*>);
static_assert(std::is_same_v<decltype(pointer), int*>);
static_assert(std::is_same_v<decltype(wholeArray), int (&)[3]>);
static_assert(std::is_same_v<decltype(one), int>);
static_assert(std::is_same_v<decltype(list), std::initializer_list<int>>);
copied = 99;
alias = 20;
pointer[1] = 7;
std::cout << source << ' ' << copied << ' ' << wholeArray[1] << '\n';
}출력은 20 99 7입니다. copied는 별도 정수라서 99를 대입해도 원본을 바꾸지 않습니다. alias는 source의 다른 이름이므로 원본이 20이 됩니다. 배열에서 추론한 pointer는 배열 전체의 복사본이 아니라 첫 원소를 가리키는 포인터입니다. 따라서 그 포인터로 두 번째 원소를 바꾸면 wholeArray[1]도 7입니다. pointerCopy에서는 포인터 자체의 최상위 const가 사라졌지만, 가리키는 정수의 const는 남습니다.
독립 연습
const int limit = 6; auto a = limit; auto& b = limit; int values[] = {9, 8}; auto p = values; auto& all = values;를 순서대로 선언한 뒤 a = 12; p[0] = 4;를 실행합니다. a, b, p, all의 선언 타입과 마지막 limit, a, all[0] 값을 적어 보세요. b = 12;를 추가할 수 있는지도 판단하세요.
타입부터 확인하는 풀이
a는 int, b는 const int&, p는 int*, all은 int (&)[2]입니다. a는 복사본이므로 limit은 6이고 a는 12입니다. p[0]은 원래 배열 원소를 바꾸므로 all[0]은 4입니다. b는 const 정수를 참조하므로 대입문을 추가하면 컴파일 오류가 납니다. 같은 원본에서 시작해도 선언 형태에 따라 수정할 대상과 허용되는 연산이 달라집니다.
추론 규칙은 C++ 작업 초안의 auto 선언·중괄호 초기화 규칙을 참고할 수 있습니다. 여기서 실습한 문법은 C++17 범위입니다.