C++11에서 도입된 후행 반환 타입 문법을 소개하고, 배경과 장단점, C++14의 반환 타입 추론과의 관계를 예제와 함께 정리합니다.
2022년 1월 28일
얼마 전 clang-tidy로 PMP 라이브러리 코드를 최신화하고 있었습니다. 그 과정에서 함수의 후행 반환 타입(trailing return type)을 사용하라는 제안을 받았습니다. 이 C++11 기능을 그동안 깊게 보지 않았었는데요. 이 글에서는 간단한 소개와 함께 장단점을 정리해 보겠습니다.
후행 반환 타입은 함수의 반환 타입을 선언하는 C++11의 대안 문법입니다. 기존에는 함수 이름 앞에 반환 타입을 썼습니다.
int max(int a, int b);
새 문법에서는 함수 이름 앞에 auto를 쓰고, 반환 타입을 함수 이름과 매개변수 뒤에 씁니다.
auto max(int a, int b) -> int;
처음에는 C++ 개발자에게 다소 낯설게 보일 수 있습니다. 하지만 Swift, Rust, Haskell 같은 다른 언어들도 비슷한 표기를 사용합니다. auto를 func 키워드처럼 생각하면 도움이 될 수 있습니다.
이 대안 문법의 주된 동기는 템플릿 인자에 따라 반환 타입이 달라지는 함수 템플릿의 반환 타입을 명시하기 위함입니다. 다음 함수를 보세요.
template<typename A, typename B>
??? multiply(A a, B b) { return a*b; }
이 경우 multiply()의 일반적인 반환 타입은 decltype(a*b)입니다. 그러나 이걸 기존 함수 문법으로는 쓸 수 없습니다. C++는 좌에서 우로 파싱하기 때문에, 함수 이름 앞에서 반환 타입을 지정할 때는 매개변수 a와 b가 아직 스코프에 들어와 있지 않습니다.
declval()을 사용하는 우회 방법이 있긴 하지만, 헷갈립니다.
template<typename A, typename B>
decltype(std::declval<A&>() * std::declval<B&>())
multiply(A a, B b) { return a*b; }
대안 문법은 이 문제를 해결합니다. 이제 올바른 반환 타입을 곧바로 쓸 수 있습니다.
template<typename A, typename B>
auto multiply(A a, B b) -> decltype(a*b) { return a*b; }
반환 타입을 표기하는 방법이 두 가지가 생겼으니, 어느 것을 써야 할지라는 질문이 생깁니다. 먼저 장점을 봅시다.
저는 새 문법의 강한 근거로 일관성을 꼽습니다. 위의 예시에서 보았듯, 새 문법은 특정 상황에서 유용합니다. 람다 함수도 후행 반환 타입을 사용합니다.
auto square = [] (int n) -> int { return n*n; }
새 문법은 현대 C++가 좌에서 우로 읽히는 문법으로 이동하는 일반적 흐름과도 맞닿아 있습니다.
auto message = std::string{"Hello"};
using table = std::map<std::string, int>;
auto square = [] (int n) -> int { return n*n; }
자세한 내용은 Herb Sutter의 Almost Always Auto Style 글을 참고하세요.
함수 선언을 읽을 때 제가 가장 관심 있는 것은 그 함수가 무엇을 하는지 "이해"하는 것입니다. 함수가 적절히 명명되어 있다는 가정하에, 가장 중요한 정보는 반환 타입이 아니라 함수 이름에 담깁니다. 따라서 이름을 먼저 두는 것이 합리적입니다.
후행 반환 타입은 수학적 표기, 그리고 우리가 수학에서 함수에 대해 이야기하는 방식과도 더 가깝습니다. "함수를 …라고 하자"와 같은 식이죠.
후행 반환 타입을 사용하면 함수 이름이 보기 좋게 정렬됩니다.
auto is_empty() -> bool;
auto number_of_vertices()() -> int;
auto vertices_begin() -> VertexIterator;
또, 클래스 내부에 정의된 타입(위의 VertexIterator처럼)을 반환하는 경우처럼, 표준 표기보다 더 짧아질 때가 있습니다. 기존 문법에서는 클래스 스코프를 명시적으로 적어야 합니다.
SurfaceMesh::VertexIterator SurfaceMesh::vertices_begin() { ... }
새 문법은 조금 더 간결합니다.
auto SurfcaceMesh::vertices_begin() -> VertexIterator { ... }
단점으로 떠오르는 것은 두 가지입니다.
분명 대부분의 기존 코드는 새 문법을 사용하지 않습니다. 따라서 모든 개발자가 이에 익숙한 것은 아닙니다. 또한 override와 관련한 함정이 하나 있는데, override는 함수 타입의 일부가 아니므로 반환 타입 뒤에 와야 합니다.
virtual auto foo() -> int override;
후행 반환 타입을 사용하는 오픈 소스 라이브러리들도 일부 보았습니다. 그러나 아직은 소수입니다. 새 문법을 사용하면 C++ 생태계의 큰 부분과 "일관성이 깨질" 수 있습니다.
새 문법은 특히 사소한 함수에서 더 많은 타이핑을 요구하기도 합니다. 다음과 같이 쓰는 것을 선호하는 사람은 많지 않을 것입니다.
auto func() -> void;
대신 이렇게 쓰겠죠.
void func();
이런 경우를 제외하기 시작하면, 앞서의 일관성 주장은 힘을 잃게 됩니다.
후행 반환 타입을 어디에나 쓰는 것이 좋은 생각인지는 잘 모르겠습니다. 일관성이라는 근거와, 가독성/이해 측면의 잠재적 개선 덕분에 거의 마음이 기울 뻔했지만, 결국은 있으면 좋은 정도의 요소이지 그 이상은 아닙니다.
낯섦은 새 문법을 사용하지 않을 중요한 이유입니다. 게다가 C++14에서는 반환 타입을 자동으로 추론하는 기능이 도입되었습니다. 이 덕분에 위에서 본 multiply() 예시는 더 간단해집니다.
template<typename A, typename B>
auto multiply(A a, B b) { return a*b; }
이는 새 반환 문법이 꼭 필요한 경우의 수를 줄여 주며, 그 결과 일반적인 함수 선언에서는 다소 어색한 선택이 되었습니다.
다만 auto 반환 타입 추론이 항상 바람직한 것은 아니라는 점을 기억하세요. 위의 예시처럼 짧은 함수에는 분명 괜찮습니다. 하지만 일반적으로는, 사용자들이 IDE에 의존하거나 구현을 끝까지 읽고 이해하지 않아도 무엇을 얻게 되는지 알 수 있도록, 저는 명시적 반환 타입을 선호합니다.
Reddit에서 토론에 참여하세요. 댓글 모두 감사합니다. 특히 std::declval<> 매개변수 타입과 관련된 부정확함을 지적해 준 u/jwakely에게 특별히 감사드립니다.