Post

Modern C++에서는 auto를 어디까지 사용해야 할까?

Modern C++에서 auto와 명시적 타입 선언을 언제 사용하는 것이 좋은지, 최근 C++ 스타일과 함께 설명한 글입니다.

Modern C++에서는 auto를 어디까지 사용해야 할까?


Modern C++에서는 모든 변수를 auto로 선언할까?


C++ 코드를 보다 보면 예전보다 auto를 사용하는 경우가 상당히 많아졌다.
예를 들어 예전에는 아래처럼 iterator의 타입을 직접 작성하는 경우가 많았다.

1
std::vector<int>::iterator it = values.begin();

하지만 Modern C++에서는 보통 다음과 같이 작성한다.

1
auto it = values.begin();

std::vector<int>::iterator라는 긴 타입을 직접 작성할 필요가 없고,

1
values.begin()

이라는 표현만 봐도 이것이 iterator라는 것을 어느 정도 알 수 있기 때문이다.
그렇다면 최근 C++에서는 모든 변수 선언을 아래처럼 auto로 작성하는 것이 좋은 것일까?

1
2
3
4
auto count = 10;
auto distance = 20.0;
auto name = std::string{"hello"};
auto values = std::vector<int>{1, 2, 3};

꼭 그렇지는 않다.
Modern C++에서 auto를 적극적으로 사용하는 것은 맞지만 중요한 기준은 타입을 최대한 숨기는 것이 아니라 불필요한 타입 반복을 제거하면서 코드의 의미는 명확하게 유지하는 것이라고 보는 것이 좋다.


auto는 타입을 자동으로 추론한다


auto를 사용하면 컴파일러가 초기화 표현식을 보고 변수의 타입을 결정한다.

1
auto value = 10;

위 코드에서 10int 타입이므로 value 역시 int가 된다.
개념적으로 다음과 같다.

1
int value = 10;

다른 타입도 마찬가지이다.

1
2
3
auto pi = 3.14;
auto name = std::string{"hello"};
auto ptr = std::make_unique<int>(10);

각 타입은 다음과 같이 추론된다.

1
2
3
double
std::string
std::unique_ptr<int>

따라서 auto는 타입이 없는 변수를 만드는 것이 아니다.
타입은 존재하지만 그 타입을 프로그래머 대신 컴파일러가 추론하는 것이다.


auto는 초기값이 필요하다


auto는 오른쪽의 초기값을 이용해서 타입을 결정한다.
따라서 아래와 같이 사용할 수는 없다.

1
auto value;

컴파일러 입장에서는 value가 어떤 타입인지 판단할 정보가 없기 때문이다.
반면 아래 코드는 가능하다.

1
auto value = 10;

따라서 다음과 같이 빈 vector를 먼저 만들어야 한다면

1
std::vector<int> values;

명시적인 타입을 작성하는 것이 자연스럽다.
아래와 같이 작성할 수는 없다.

1
auto values; // error

초기값이 있는 경우에는 가능하다.

1
auto values = std::vector<int>{1, 2, 3};

이 경우 valuesstd::vector<int>가 된다.


그렇다면 vector를 굳이 직접 쓸 필요가 있을까?


예를 들어 다음 코드가 있다고 해보자.

1
std::vector<int> scores{10, 20, 30};

이를 아래처럼 작성할 수도 있다.

1
auto scores = std::vector<int>{10, 20, 30};

두 코드 모두 scores의 타입은 std::vector<int>이다.
하지만 두 번째 코드가 반드시 더 좋은 것은 아니다.

1
std::vector<int> scores{10, 20, 30};

에서는 변수 선언을 보는 순간 이 변수는 int를 저장하는 vector이다라는 사실을 바로 알 수 있다.
반면 auto는 오른쪽 표현식을 확인해야 타입을 알 수 있다.
따라서 타입 자체가 프로그램의 의미를 설명해 준다면 명시적인 타입을 사용하는 것이 오히려 코드를 이해하기 쉬울 수 있다.


C++17 이후에는 CTAD도 사용할 수 있다


C++17부터는 Class Template Argument Deduction(CTAD) 기능이 추가되었다.
따라서 다음과 같이 작성할 수도 있다.

1
std::vector values{1, 2, 3};

컴파일러는 초기값을 보고

1
std::vector<int>

라고 추론한다.
따라서 다음 세 코드는 비슷한 결과를 만들 수 있다.

1
2
3
std::vector<int> values{1, 2, 3};
auto values2 = std::vector<int>{1, 2, 3};
std::vector values3{1, 2, 3};

이 경우 개인이나 프로젝트의 코딩 스타일에 따라 선택할 수 있다.
다만

1
std::vector values{1, 2, 3};

처럼 컨테이너의 종류는 보여주면서 반복되는 template 인자만 컴파일러에게 추론시키는 방식도 Modern C++에서 유용하게 사용할 수 있다.


타입이 이미 오른쪽에 보인다면 auto가 편리하다


auto가 특히 유용한 경우는 오른쪽 표현식을 보면 타입을 쉽게 알 수 있을 때이다.
예를 들어 다음 코드가 있다.

1
std::unique_ptr<Robot> robot = std::make_unique<Robot>();

Robot이라는 타입이 한 줄 안에서 반복되고 있다.

1
2
std::unique_ptr<Robot>
std::make_unique<Robot>()

이런 경우에는 다음과 같이 작성하는 것이 훨씬 간단하다.

1
auto robot = std::make_unique<Robot>();

오른쪽에 std::make_unique<Robot>()가 있기 때문에 robotRobot을 관리하는 smart pointer라는 사실도 쉽게 알 수 있다.
따라서 타입을 직접 작성해도 추가적으로 전달되는 정보가 거의 없다.
이런 경우 auto를 사용하는 것이 코드 중복을 줄이고 타입을 잘못 작성할 가능성도 줄여준다.


iterator는 auto를 사용하기 좋은 대표적인 경우이다


STL iterator 타입은 상당히 길어질 수 있다.
예를 들어 다음과 같은 코드를 생각해 보자.

1
std::vector<int>::iterator it = values.begin();

컨테이너가 복잡해지면 타입도 더 길어진다.

1
2
std::unordered_map<std::string,
                   std::vector<int>>::iterator it = data.begin();

하지만 실제 코드에서 중요한 것은 iterator의 정확한 타입 이름이 아니라 이 변수가 data의 iterator라는 사실인 경우가 많다.
따라서 다음처럼 작성하는 것이 훨씬 간단하다.

1
auto it = data.begin();

특히 STL algorithm을 사용할 때도 auto가 자연스럽다.

1
auto it = std::find(values.begin(), values.end(), 10);

C++20의 ranges를 사용하면 다음과 같이 작성할 수도 있다.

1
auto it = std::ranges::find(values, 10);

이 경우 반환되는 iterator의 정확한 타입을 직접 작성할 이유가 거의 없다.


Modern C++에서 auto가 많아진 이유


최근 C++ 코드에서 auto가 많이 보이는 이유는 단순히 코드 길이를 줄이기 위해서만은 아니다.
Modern C++에서는 다음과 같은 기능들을 많이 사용한다.

1
2
3
4
5
6
7
8
STL iterator
lambda
smart pointer
template
ranges
views
structured binding
concepts

이 기능들은 반환 타입이나 중간 타입이 상당히 복잡해질 수 있다.
예를 들어 iterator의 구체적인 타입은 프로그램을 작성하는 사람 입장에서 중요하지 않은 경우가 많다.

1
auto it = container.begin();

ranges와 views를 조합하면 중간 타입은 더욱 복잡해질 수 있다.

1
2
3
4
5
6
7
8
9
10
auto result =
    values
    | std::views::filter([](int value)
      {
          return value > 0;
      })
    | std::views::transform([](int value)
      {
          return value * 2;
      });

이런 코드에서 result의 실제 타입을 직접 작성하려고 하면 오히려 코드의 핵심 내용을 이해하기 어려워진다.
따라서 Modern C++에서는 구체적인 타입보다 동작이나 의미가 중요한 경우 타입 추론을 적극적으로 사용하는 방향 으로 발전했다고 볼 수 있다.


타입 자체가 의미를 전달한다면 명시적으로 쓰는 것도 좋다


반대로 다음과 같은 코드를 생각해 보자.

1
2
3
auto count = 0;
auto distance = 0.0;
auto enabled = false;

이 코드가 잘못된 것은 아니다.
하지만 다음과 같이 작성해도 충분히 간단하다.

1
2
3
int count = 0;
double distance = 0.0;
bool enabled = false;

int, double, bool 자체가 짧기도 하고 변수의 의미를 이해하는 데 도움이 될 수도 있다.
clang-tidy의 modernize-use-auto 역시 모든 변수를 무조건 auto로 바꾸지 않는다.
예를 들어

1
int value = 42;

를 단순히

1
auto value = 42;

로 변경하는 것은 일반적으로 큰 가독성 개선이 없다고 본다.
따라서 auto를 사용할 수 있는가auto를 사용하는 것이 좋은가는 다른 문제이다.


함수 반환 타입을 알기 어려운 경우에는 명시적인 타입이 좋을 수 있다


다음 코드가 있다고 해보자.

1
auto result = process();

문법적으로는 아무 문제가 없다.
하지만 이 코드만 보고는 result의 타입을 알기 어렵다.
process()가 무엇을 반환하는지 확인해야 한다.
예를 들어 다음 중 어떤 타입인지 알 수 없다.

1
2
3
4
5
bool
int
ProcessResult
std::optional<ProcessResult>
std::expected<ProcessResult, Error>

이런 경우에는 타입이 중요한 정보를 전달한다면 명시적으로 작성하는 것이 좋을 수 있다.

1
ProcessResult result = process();

또는

1
std::optional<ProcessResult> result = process();

코드만 읽어도 process()의 결과가 무엇인지 보다 쉽게 알 수 있다.
따라서 auto는 타입 정보를 제거한다기보다 굳이 보여줄 필요가 없는 타입 정보를 생략하는 기능으로 생각하는 것이 좋다.


auto는 암시적 타입 변환을 막을 수도 있다


명시적 타입 선언과 auto는 단순히 코드 모양만 다른 것은 아니다.
다음 코드를 생각해 보자.

1
double value = getValue();

만약 getValue()int를 반환한다면 반환된 intdouble로 변환된다.
반면 다음과 같이 작성하면

1
auto value = getValue();

value는 함수가 실제로 반환한 타입이 된다.
만약 반환 타입이 int라면 value 역시 int이다.

1
double value = getValue();

나는 결과를 double로 사용하고 싶다라는 의미를 가질 수 있고,

1
auto value = getValue();

함수가 반환하는 타입 그대로 사용하겠다라는 의미에 더 가깝다.
따라서 특정 타입으로 변환되는 것이 코드의 의도라면 명시적인 타입을 사용하는 것이 좋다.


auto를 사용할 때 const와 reference는 주의해야 한다


auto를 사용할 때 특히 주의해야 하는 부분이 const와 reference이다.
다음 코드가 있다고 해보자.

1
2
3
const int value = 10;

auto copy = value;

copy의 타입은 const int가 아니라

1
int

이다.
일반적인 auto 타입 추론에서는 이러한 top-level const가 제거된다.
reference 역시 마찬가지로 주의해야 한다.

1
2
3
4
int value = 10;
int& ref = value;

auto copy = ref;

copyint&가 아니라 새로운 int 객체이다.
즉 값이 복사된다.

1
copy = 20;

을 해도 원래 value에는 영향을 주지 않는다.


reference가 필요하다면 auto&를 사용한다


reference로 사용하고 싶다면 auto&를 사용해야 한다.

1
2
3
int value = 10;

auto& ref = value;

이 경우 refint&가 된다.
따라서

1
ref = 20;

을 수행하면

1
value

역시 20이 된다.
읽기만 하면서 복사를 피하고 싶다면 다음 패턴도 매우 자주 사용된다.

1
const auto& value = object.getValue();

특히 container를 순회할 때 많이 사용된다.

1
2
3
4
for (const auto& item : items)
{
    std::cout << item << '\n';
}

이 방식은 각 요소를 복사하지 않고 reference로 접근하면서 수정하지 못하도록 한다.
반대로 요소를 수정해야 한다면 다음과 같이 사용할 수 있다.

1
2
3
4
for (auto& item : items)
{
    item.update();
}

따라서 Modern C++에서는 단순히 auto만 아는 것보다

1
2
3
4
auto
auto&
const auto&
auto&&

가 각각 어떤 의미인지 이해하는 것이 중요하다.


auto는 복사가 발생하는지 숨길 수도 있다


예를 들어 다음 코드가 있다고 해보자.

1
2
3
std::vector<Data> values;

auto value = values[0];

values[0]은 reference를 반환하지만

1
auto value

로 받으면 새로운 Data 객체가 복사된다.
개념적으로 다음과 같다.

1
Data value = values[0];

복사를 피하려면 다음과 같이 작성해야 한다.

1
auto& value = values[0];

수정하지 않을 것이라면 일반적으로 다음과 같은 코드가 더 안전하다.

1
const auto& value = values[0];

따라서 auto를 많이 사용하는 Modern C++ 코드에서는 타입 추론뿐만 아니라 value, reference, const의 관계를 이해하는 것이 매우 중요하다.


structured binding에서는 auto가 자연스럽다


C++17부터 structured binding을 사용할 수 있다.
예를 들어 std::map에서 값을 찾거나 pair를 반환하는 함수를 사용할 때 다음과 같이 작성할 수 있다.

1
auto [it, inserted] = values.insert({10, "hello"});

기존 방식이라면 반환되는 std::pair의 타입을 직접 다뤄야 할 수 있다.
하지만 structured binding을 사용하면

1
2
it
inserted

라는 각각의 의미 있는 변수로 바로 분리할 수 있다.
또 다른 예로 다음과 같은 함수가 있다고 해보자.

1
std::pair<int, int> getPosition();

다음처럼 사용할 수 있다.

1
auto [x, y] = getPosition();

이런 경우에는 auto가 단순히 타입 정보를 숨기는 것이 아니라 객체의 각 요소에 의미 있는 이름을 부여하도록 도와준다.
따라서 structured binding은 Modern C++에서 auto를 사용하는 대표적인 경우 중 하나이다.


중괄호 초기화에서는 auto 타입 추론을 주의해야 한다


auto{} 초기화를 같이 사용할 때는 타입 추론 규칙을 알고 있는 것이 좋다.
예를 들어 다음 코드는

1
auto value{10};

C++17 이후에는 int로 추론된다.
하지만 다음 코드는 다르다.

1
auto values = {1, 2, 3};

이 경우 타입은

1
std::initializer_list<int>

이다.
즉 다음 두 코드는 서로 다른 타입이 될 수 있다.

1
2
3
auto value{10};       // int

auto values = {10};   // std::initializer_list<int>

따라서 initializer list와 auto를 같이 사용할 때는 컴파일러가 어떤 타입을 추론하는지 주의할 필요가 있다.


auto와 std::make_unique는 잘 어울린다


smart pointer를 만들 때 auto는 특히 자연스럽다.
예를 들어 다음과 같이 작성할 수 있다.

1
2
std::unique_ptr<Robot> robot =
    std::make_unique<Robot>();

문제는 없지만 Robot이라는 타입 정보가 반복된다.
따라서 보통 다음과 같이 작성할 수 있다.

1
auto robot = std::make_unique<Robot>();

std::make_unique<Robot>()이라는 표현 자체가 타입을 충분히 보여주고 있기 때문이다.
std::make_shared도 마찬가지이다.

1
auto robot = std::make_shared<Robot>();

이런 경우에는 auto를 사용하는 것이 코드의 의미를 잃지 않으면서 반복을 줄여준다.


cast 결과를 받을 때도 auto가 유용할 수 있다


다음과 같은 코드가 있다고 해보자.

1
Robot* robot = dynamic_cast<Robot*>(object);

Robot*이라는 타입이 두 번 반복된다.

1
2
Robot*
dynamic_cast<Robot*>

따라서 다음과 같이 작성할 수도 있다.

1
auto* robot = dynamic_cast<Robot*>(object);

오른쪽의 dynamic_cast<Robot*>만 봐도 반환되는 타입을 알 수 있기 때문이다.
이처럼 타입이 오른쪽 표현식에 명확하게 나타나는 경우에는 auto를 이용해 중복을 줄이는 것이 유용하다.


함수의 반환 타입에도 auto를 사용할 수 있다


C++14 이후에는 함수의 반환 타입도 auto로 추론할 수 있다.
예를 들어 다음과 같이 작성할 수 있다.

1
2
3
4
auto add(int a, int b)
{
    return a + b;
}

컴파일러는 반환 표현식을 보고 반환 타입을 int로 추론한다.
간단한 내부 함수나 lambda에서는 유용할 수 있다.
하지만 모든 함수의 반환 타입을 auto로 작성하는 것이 좋은 것은 아니다.
예를 들어 다음 함수가 있다고 해보자.

1
2
3
4
auto loadData()
{
    // 많은 코드
}

함수 구현을 읽지 않고는 반환 타입을 알기 어렵다.
반면

1
2
3
4
std::vector<Data> loadData()
{
    // ...
}

라고 작성하면 함수 선언만 보고도 어떤 값을 반환하는지 알 수 있다.
특히 외부에 공개되는 API나 header에 선언되는 함수에서는 반환 타입이 인터페이스의 중요한 일부가 된다.
따라서 반환 타입 추론 역시 타입을 생략할 수 있다는 이유만으로 사용하는 것이 아니라 가독성과 인터페이스를 고려해서 사용해야 한다.


C++20에서는 함수 인자에도 auto를 사용할 수 있다


C++20부터는 abbreviated function template을 이용해서 함수 인자에 auto를 사용할 수도 있다.

1
2
3
4
void print(const auto& value)
{
    std::cout << value << '\n';
}

개념적으로는 template 함수와 비슷하다.

1
2
3
4
5
template <typename T>
void print(const T& value)
{
    std::cout << value << '\n';
}

C++20의 concepts와 같이 사용할 수도 있다.

1
2
3
4
void print(std::integral auto value)
{
    std::cout << value << '\n';
}

이처럼 최근 C++에서는 타입 추론을 언어 여러 부분에서 적극적으로 지원하고 있다.
다만 일반적인 함수의 인자 타입이 하나로 정해져 있다면 명시적인 타입을 쓰는 것이 함수의 인터페이스를 이해하기 쉬운 경우도 많다.
따라서 이것 역시 상황에 따라 선택하는 것이 좋다.


최근 C++ 스타일 가이드의 방향


C++ Core Guidelines에서는 auto를 사용하여 타입 이름의 불필요한 반복을 피하는 것을 권장한다.
반면 Google C++ Style Guide에서는 단순히 명시적인 타입을 작성하기 귀찮다는 이유만으로 auto를 사용하는 것은 권장하지 않는다.
중요한 기준은 타입 추론을 사용했을 때

1
2
3
4
코드가 더 명확해지는가?
중복이 줄어드는가?
잘못된 타입을 작성할 가능성이 줄어드는가?
독자가 타입을 쉽게 추론할 수 있는가?

이다.
즉 두 스타일의 방향을 종합하면 auto를 적극적으로 사용하되, 타입 자체가 중요한 정보라면 숨기지 않는다정도로 이해할 수 있다.


정리


Modern C++에서는 auto를 적극적으로 사용한다.
하지만 모든 타입을 auto로 바꾸는 것이 최신 C++ 스타일이라는 의미는 아니다.
auto의 목적은 타입을 숨기는 것이 아니라 컴파일러가 이미 알고 있는 타입을 프로그래머가 불필요하게 반복해서 작성하지 않도록 하는 것이다.
예를 들어 다음과 같은 경우에는 auto가 자연스럽다.

1
2
3
4
5
6
7
auto it = values.begin();

auto robot = std::make_unique<Robot>();

auto result = std::ranges::find(values, target);

auto [x, y] = getPosition();

반면 다음과 같은 선언도 충분히 좋은 Modern C++ 코드이다.

1
2
3
4
5
6
7
int count = 0;

double distance = 0.0;

std::vector<Point> obstacles;

RobotState state;

함수 호출만으로 반환 타입을 쉽게 알 수 없는 경우도 명시적인 타입이 도움이 될 수 있다.

1
DetectionResult result = detector.process(image);

타입 자체가 중요한 정보를 전달하기 때문이다.
또한 auto를 사용할 때는 단순히 타입 추론만 생각해서는 안 된다.

1
2
3
4
5
auto value = values[0];        // 복사

auto& value = values[0];       // reference

const auto& value = values[0]; // const reference

처럼 const와 reference가 어떻게 추론되는지도 이해해야 한다.
결국 Modern C++에서 가장 실용적인 기준은 다음과 같이 정리할 수 있다.

1
2
3
4
5
6
오른쪽을 보면 타입이 명확하다 -> auto
타입이 길고 구체적인 타입 자체가 중요하지 않다 -> auto
타입 이름이 반복된다 -> auto
타입 자체가 변수의 의미를 설명한다 -> 명시적 타입
함수 반환 타입을 쉽게 알 수 없다 -> 필요하면 명시적 타입
특정 타입으로 변환하려는 의도가 있다 -> 명시적 타입

따라서 Modern C++에서 중요한 것은 auto를 많이 사용하는 것이 아니라 코드를 읽는 사람이 가장 쉽게 의도를 이해할 수 있는 타입 표현을 선택하는 것이라고 볼 수 있다.

This post is licensed under CC BY 4.0 by the author.