원문: Elements of Modern C++ Style
“C++11은 새로운 언어처럼 느껴진다.” – Bjarne Stroustrup
C++11 표준은 많은 유용한 특징들을 제공한다. 이 페이지는 특히 C++11을 C++98에 비해 새로운 언어라고 실제로 느낄 수 있도록 만드는 그런 특징들에 대해서만 초점을 맞춘다. 왜냐하면,
다른 위대한 C++11 특징들을 사용해봐라. 하지만 우선 이런 특징들에 익숙해져라. 왜냐하면 이 특징들은 왜 C++11 코드가 깨끗하고 안전하고 빠른지-어떤 다른 현대적인 주류 언어들로 작성된 코드만큼이나 깨끗하고 안전한-를, 그리고 C++의 전통적인 최고의 성능 덕분에 여전히 강력하다는 것을 속속들이 보여주기 때문이다.
주의사항:
역주
Strunk & White는 The Elements of Style이라는 글쓰기 안내 책을 뜻함
auto를 가능하면 어디에나 사용하라. 두 가지 이유에서 쓸모가 있다. 첫째, 가장 명백하게 우리가 이미 언급했고 컴파일러가 이미 알고 있는 타입 이름을 반복하는 것을 피하는 게 편리하기 때문이다.
// C++98
map::iterator i = m.begin();
// C++11
auto i = begin(m);
둘째, 대부분의 람다 함수의 타입처럼 타입이 모르는 이름이나 말로 표현할 수 없는 이름을 가지고 있어서 쉽게 이름을 말할 수 없거나 전혀 말할 수 없을 때, 단순히 편한 것 이상이다.
// C++98
binder2nd< greater > x = bind2nd( greater(), 42 );
// C++11
auto x = [](int i) { return i > 42; };
auto가 코드의 의미를 변화시키지 않는다는 것을 유의하라. 코드는 여전히 정적으로 타입을 지정하고 있고 모든 표현식의 타입은 이미 명료하다. C++ 언어에서는 더 이상 반복해서 타입의 이름을 다시 말할 필요가 없다.
우리가 원하는 타입을 (다시) 말하지 않는 것이 우리가 우연히 다른 타입을 얻게 될 거라는 것을 의미할 수도 있을 것 같기 때문에, 어떤 사람들은 auto를 여기서 사용하는 것을 처음부터 걱정한다. 명시적으로 타입 변환을 강화하는 것을 원한다면 그건 좋다. 대상의 타입을 말해라. 그러나 거의 대부분의 시간에는 그냥 auto를 사용해라. 실수로 다른 타입을 얻는 경우는 드물 것이고 심지어 그런 경우에도 C++ 언어의 강력한 정적 타이핑의 의미하는 건, 그 변수가 가지지 않은 멤버 함수를 호출하거나 아니면 그것(그 타입의 변수)이 아닌 어떤 것으로 그것을 사용하려고 시도하기 때문에 컴파일러가 보통은 당신이 (그런 상황이라는 걸) 알게 해 줄 거라는 것이다.
항상 표준 스마트 포인터(smart pointer)와 소유권 없는 보통 포인터를 사용하라. 스스로 저수준 자료 구조를 만드는 (심지어 그렇게 해서 클래스 경계 안에서 잘 은닉화(캡슐화; encapsulation)되어 보관하는) 드문 경우를 제외하고는, 절대로 소유권을 가지는 보통 포인터와 delete를 사용하지 마라.
당신이 다른 객체의 유일한 소유자라는 걸 알고 있다면, 유일한 소유권을 표현하기 위해 unique_ptr을 사용하라. “new T” 표현식은 즉시 보통 unique_ptr을 소유하고 있는 다른 객체를 초기화할 것이다.
// C++11 Pimpl Idiom
class widget {
widget();
~widget();
private:
class impl;
unique_ptr pimpl;
};
// in .cpp file
class impl {
:::
};
widget::widget() : pimpl{ new impl{ /*...*/ } }
{
}
widget::~widget() = default;
역주
Pimpl Idiom: Pimpl은 보통 private implementation이나 pointer to implementaion를 의미한다. Pimpl Idiom은 클래스의 내부 구조가 변경될 때마다 클래스의 선언을 포함한 헤더를 다시 전처리-컴파일해야 하는 부담을 최소화하기 위해 실제 구현 클래스를 따로 두고 그것을 가리키는 포인터를 private 멤버 변수로 포함하는 관용적인 표현 방식을 의미한다. Pimpl Idiom은 “Compiler Firewall”으로도 알려져 있다.
공유된 소유권을 표현하려면 shared_ptr을 사용하라. 공유 객체들을 효율적으로 생성하려면 make_shared를 사용하는 것을 선호하라.
// C++98
widget* pw = new widget();
:::
delete pw;
// C++11
auto pw = make_shared();
순환을 끊고 선택 가능성을 표현하려면 weak_ptr을 사용하라. (예: 객체 캐시의 구현)
// C++11
class gadget;
class widget {
private:
shared_ptr g; // 공유된 소유권이라면
};
class gadget {
private:
weak_ptr w;
};
다른 객체가 더 오래 살아남을 거라는 걸 알고 그걸 관찰하고 싶다면 (소유권 없는) 보통 포인터를 사용하라.
// C++11
class node {
vector< unique_ptr > children;
node* parent;
public:
:::
};
항상 null 포인터 값을 위해 nullptr을 사용하고 절대로 리터럴 0이나 정수나 포인터가 될 수 있어서 모호한 매크로 NULL을 사용하지 마라.
// C++98
int* p = 0;
// C++11
int* p = nullptr;
범위 for
범위 기반의 for 루프가 순서대로 모든 원소를 방문하기 위한 훨씬 더 편리한 방법이다.
// C++98
for( vector::iterator i = v.begin(); i != v.end(); ++i ) {
total += *i;
}
// C++11
for( auto d : v ) {
total += d;
}
항상 비 멤버 begin(x)와 end(x)를 사용하라.(x.begin()과 x.end()가 아님) 왜냐하면 begin(x)와 end(x)가 확장 가능하고 x.begin()과 x.end() 멤버 함수를 제공하는 STL 스타일을 따르는 컨테이너 뿐만 아니라 모든 컨테이너 타입에-심지어는 배열까지-동작되도록 조정될 수 있기 때문이다.
STL 스타일의 x.begin()과 x.end()이 아닌 iteration을 제공하는 비 STL 컬렉션 타입을 사용한다면, 때로는 그 타입을 위한 독자적인 비 멤버 begin(x)와 end(x) overload를 작성할 수 있고 그러면 STL 컨테이너를 위한 예전의 같은 코딩 스타일을 사용하는 타입의 컬렉션을 탐색(traverse)할 수 있다. 표준은 모범을 보여주고 있다. C 배열은 그런 타입이고 표준은 배열에 대해 begin과 end를 제공한다.
vector v;
int a[100];
// C++98
sort( v.begin(), v.end() );
sort( &a[0], &a[0] + sizeof(a)/sizeof(a[0]) );
// C++11
sort( begin(v), end(v) );
sort( begin(a), end(a) );
람다는 게임-변경자(game changer)이고 코드를 더 우아하고 빠르게 만들기 위해 코드를 작성하는 방식을 자주 바꾸게 할 것이다. 람다는 존재하는 STL 알고리즘을 대충 100배는 더 유용하게 만든다. 더 새로운 C++ 라이브러리는 갈수록 람다를 변수로 가정하여 설계될 것이고(예: PPL) 어떤 라이브러리는 심지어 그걸 사용하려면 람다를 작성하는 게 필요하다.(예: C++ AMP)
역주
PPL: Parallel Patterns Library; 병렬 패턴 라이브러리
AMP: Accelerated Massive Parallelism; 대규모 병렬 가속
여기 빠른 예제가 하나 있다. v에서 s>x이고 s<y인 첫번째 요소를 찾아라. C++11에서 가장 간단하고 깨끗한 코드는 표준 알고리즘을 사용하는 것이다.
// C++98: 드러난 루프를 작성(std::find_if를 사용하면 쓸데없이 어렵다)
vector::iterator i = v.begin(); // i를 나중에 사용할 필요가 있으니까
for( ; i != v.end(); ++i ) {
if( *i > x && *i < y ) break;
}
// C++11: std::find_if를 사용
auto i = find_if( begin(v), end(v), [=](int i) { return i > x && i < y; } );
루프나 실제로는 C++ 언어에 없는 유사한 언어의 특징을 원하는가? 문제없다. 템플릿 함수(라이브러리 알고리즘)로 그걸 작성하라. 그리고 언어의 특징인 것처럼 거의 같은 편한 수준으로, 그러나 람다가 실제로 라이브러리이고 내장된(변경 불가능한) 언어 특징이 아니기 때문에 더 많은 유연성을 가지고, 람다를 사용할 수 있으니 람다에 감사하라.
// C#
lock( mut_x ) {
... use x ...
}
// C++11 람다없이: 이미 훌륭하고 더 유연한 (예: 타임아웃 아니면 옵션을 사용할 수 있음)
{
lock_guard hold { mut_x };
... use x ...
}
``````cpp
// C++11 람다와 헬퍼(helper) 알고리즘을 가지고: C++에서 C# 구문
// Algorithm: template void lock( T& t, F f ) { lock_guard hold(t); f(); }
lock( mut_x, [&]{
... use x ...
});
람다와 친숙해져라. 그걸 많이 사용하게 될 것이고 C++에만 있는 게 아니다 - 이미 널리 사용하능하며 여러 인기있는 주류 언어들에서 많이 사용되고 있다. 시작하기 좋은 곳은 PDC 2010에서 있었던 나의 Lambdas, Lambdas Everywhere 발표이다.
Perfect Forwarding과 같은 다른 것도 가능하지만, 이동은 복사의 최적화의 한 가지로서 가장 좋은 생각이다.
역주
Perfect Forwarding: 함수 템플릿이 내부에 있는 다른 함수로 파라미터를 넘길 때, 마치 이 함수 템플릿이 없는 것처럼 동작하는 게 가장 이상적이다. 그러나 call by value로 파라미터를 복사하게 되면 그 비용이 아깝고, 함수의 결과값이나 리터럴을 파라미터로 사용하게 되면 call by reference를 사용할 수도 없는 문제가 있다. call by const reference를 이용할 수도 있지만 이것도 파라미터가 많아지면 처리가 어렵다. 이런 문제점들이 모두 해결되는 이상적인 방법을 Perfect Forwarding이라고 부른다.
이동 시맨틱은 우리가 API를 설계하는 방식을 바꾼다. 값으로 반환(return by value)하는 것을 훨씬 더 자주 설계하게 될 것이다.
// C++98: 복사를 피하는 대안들
vector* make_big_vector(); // 선택 1: 포인터로 반환: 복사 없음, 하지만 delete를 잊지 말 것
:::
vector* result = make_big_vector();
void make_big_vector( vector& out ); // 선택 2: 레퍼런스를 전달: 복사 없음, 그러나 호출자는 이름있는 객체가 필요함
:::
vector result;
make_big_vector( result );
// C++11: 이동
vector make_big_vector(); // 보통 피호출자가 할당하는 상황에 적당함
:::
vector result = make_big_vector();
역주
vector의 대입 연산자( = )를 복사가 아니라 이동이 되도록 연산자 오버로드했기 때문에 자연스럽게 복사 대신 이동이 된다.
복사보다 더 효율적인 뭔가를 할 수 있을 때 너의 타입을 위해 이동 시맨틱을 활성화시켜라.
변하지 않은 것: 오랜 기간, 우리는 여전히 지역 변수를 부가적인 { } 중괄호없이 = 구문을 가지고 초기화할 것이다.
// C++98 or C++11
int a = 42; // 좋음, 항상 그렇듯이
새로운 것: 객체를 생성할 때 ( ) 소괄호를 사용하려고 했던 어떤 경우에는, 대신 { } 중괄호를 사용하는 걸 선호해라. 중괄호를 사용하면 여러 잠재적인 문제점들을 피하게 된다: 의도치 않게 좁아지는 변환(예: float를 int로)이 되지 않고, 때때로 의도치 않게 초기화되지 않은 POD 멤버 변수들이나 배열들을 가지지 않게 되고, 너의 코드가 컴파일은 되는데 실제로는 C++ 문법에서 선언의 모호성-Scott Meyers가 “C++의 가장 짜증나는 구문 분석”이라고 불러서 유명해진 것-때문에 변수 대신 함수를 선언하는 우연한 C++98의 놀라움을 피하게 될 것이다.
// C++98
complex c( 2.71828, 3.14159 );
int a[] = { 1, 2, 3, 4 };
vector v;
for( int i = 1; i <=4 ; ++i ) v.push_back(i);
// C++11
complex c = { 2.71828, 3.14159 }; // =는 선택적이지만 개인적으로는 그걸 선호함
int a[] = { 1, 2, 3, 4 };
vector v = { 1, 2, 3, 4 };
새로운 { } 구문은 어디에서나 매우 잘 동작한다.
// C++98
X::X( /*...*/ ) : mem1(init1), mem2(init2, init3) { /*...*/ }
// C++11
X::X( /*...*/ ) : mem1{init1}, mem2{init2, init3} { /*...*/ }
마지막으로, 가끔 함수 인자를 임시로 타입을 지정하지 않고 넘기는 것도 정말 편리하다.
void draw_rect( rectangle );
// C++98
draw_rect( rectangle( myobj.origin, selection.extents ) );
// C++11
draw_rect( { myobj.origin, selection.extents } );
내가 중괄호를 쓰지 않는 것을 선호하는 유일한 곳은 auto x = begin(v)와 같이 간단한 변수 초기화에서다. 거기서 중괄호는 코드를 불필요하게 추하게 만들 수 있다. 왜냐하면 그것이 클래스 타입이라는 걸 알아서 의도치 않은 좁아지는 변환을 걱정할 필요가 없다는 걸 알고 현대적인 컴파일러들은 이미 추가적인 복사를 생략하는 최적화를 일상적으로 수행하고 있기 때문이다. (타입이 이동-활성화되어 있더라도 추가적인 이동조차도 생략하기 때문에 최적화는 여전히 쓸모있다는 걸 주의하라.)
현대적인 C++에 더 많은 것이 있다. 훨씬 더 많이. 그리고 미래에 우리가 알고 사랑하게 될 C++11의 이런저런 특징들에 대한 더 깊이있는 조각들을 작성할 계획이다.
하지만 지금은 이게 알아야 하는 특징들의 목록이다. 이 특징들은 현대적인 C++ 스타일을 정의하고, C++ 코드가 보이는 대로 동작하게 만들고, 보거나 작성하게 될 거의 모든 현대적인 코드에서 널리 사용될 것으로 생각할, 그리고 현대적인 C++을 우리 산업이 앞으로의 기간 동안 크게 계속 의존하게 될 깨끗하고 안전하고 빠른 언어로 만드는, 핵심을 형성한다.
2011-10-30: C# lock 예제를 람다에 추가했다. unique_ptr을 먼저 소개하기 위해 스마트 포인터를 재정렬했다.
2011-11-01: 균일한 초기화를 추가했다.