C++0x

C++0x

ISO 에서 제정한 새로운 C++ 표준인 C++0x (또는 C++11) 에 대한 사항을 정리해본다.

codeproject 에 있는 아티클을 토대로 살을 붙여 작성한 것임을 밝힌다.

C++0x 에는 새로운 피쳐들이 존재하는데 순서대로 알아보도록 한다.

auto 키워드

auto 키워드는 data 의 타입을 컴파일 시점에 문맥에 맞게 선택해주는 키워드이다. 즉 프로그래머는 data 의 타입에 상관없이 auto 로 선언해주면 컴파일러가 바이너리를 만드는 시점에 문맥을 파악하여 알맞은 data 의 타입을 선언해준다.

가능한 예들

auto nVariable = 16;

이렇게 선언된 nVariable 변수는 타입이 auto 로 지정되어 있다. 컴파일러는 컴파일 시점에 오른쪽 항인 '= 16' 을 토대로 nVariable 변수의 타입을 int 로 추론한다. 즉 아래와 같이 되는 것이다.

int nVariable = 16;

눈치 챘겠는지 모르겠지만 이 auto 키워드로 변수를 선언할 시에는 반드시 초기화가 의무적이다. 즉 아래같은 코드는 불가능하다.

auto nVariable;  // error C3531: 'nVariable': a symbol whose type contains 'auto' must have an initializer

data 의 타입이 컴파일 시점에 결정되기 때문에 초기화는 의무이다.

초기화의 표현이 얼마나 복잡한지 여부는 중요하지 않다. 단 컴파일러가 명확한 data 타입을 추론할 수 없으면 error 를 뱉을 것이다.

예를 좀 더 보도록 하자.

auto nVariable1 = 56 + 54;       // int 로 추론됨
auto nVariable2 = 100 / 3;       // int 로 추론됨
 
auto nVariable3 = 100 / 3.0;     // double 로 추론됨
auto nVariable4 = labs( -127 );  // long 으로 추론됨. 'labs' 의 return 값이 long 이기 때문

위 코드에 이어 살짝 더 복잡한 예를 보자.

auto nVariable5 = sqrt( nVariable3 );  // double 로 추론됨. sqrt 는 3가지 버전으로 오버로딩 되어 있지만 double 형 파라메터를 사용했기에 double 로 추론된다.
 
auto nVariable = sqrt( nVariable4 );   // error C2668: 'sqrt' : ambiguous call to overloaded function. sqrt 의 long 파라메터 버전은 없으므로 모호하다고 판단하여 에러를 낸다.

포인터 타입 추론을 보자.

auto pVariable6 = &nVariable1;  // int* 로 추론됨
auto pVariable = &pVariable6;   // int** 로 추론됨
auto* pVariable7 = &nVariable1; // int 로 추론됨

레퍼런스 타입 추론을 보자. 비슷하다.

auto &rVariable = nVariable1;   // int 로 추론됨

new operator 의 경우

auto iArray = new int[ 10 ];    // int* 로 추론됨

const 와 volatile 의 경우

const auto PI = 3.14;              // double
volatile auto IsFinished = false;  // bool
const auto nStringLen = strlen( "z3moon.com" );

불가능한 예들

배열의 사용

auto aArray1[ 10 ];
auto aArray2[] = { 1, 2, 3, 4, 5 };
// error C3532: the element type of an array cannot be a type that contains 'auto'

함수의 파라메터나 리턴타입

auto ReturnDeduction();            // error C3551: expected a trailing return type
void ArgumentDeduction( auto x );  // error C3533: 'auto': a parameter cannot have a type that contains 'auto'

만약 함수의 파라메터나 리턴타입을 컴파일 시점에 자동으로 취하려면 template 을 사용하면 된다 ;-)

class 나 struct 에서 static const 가 아닌 멤버변수를 사용하는 경우

struct AutoStruct
{
    auto Variable = 10;
    // error C2864: 'AutoStruct::Variable' : only static const integral data members can be initialized within a class
};

여러 타입을 동시에 초기화 함으로써 타입추론이 불가능한 경우

auto a = 10, b = 10.30, s = "new";
// error C3538: in a declarator-list 'auto' must always deduce to the same type

비슷하게 함수를 이용하여 동시에 초기화 하는 경우, 다른 리턴타입을 가질 때 역시 타입추론이 불가능하다.

auto nVariable = sqrt( 100.0 ), nVariableX = labs( 100 );

주의

이 auto 키워드는 마치 축복과도 같은 기능이지만 잘못 사용할 경우 재앙으로 다가온다. 아래의 경우는 프로그래머가 float 을 의도하며 auto 키워드를 사용하였지만 실제로는 double 로 추론되는 경우다.

auto nVariable = 10.5;  // double 로 추론됨으로써 float 을 의도했을 경우 예기치 않은 결과가 나올 수 있다.

비슷한 경우로, int 를 리턴하는 함수로 초기화되는 auto 변수가 있다고 치자. 이 함수의 리턴타입을 short 이나 double 로 변경할 경우 역시 auto 타입 추론에 변화를 주기에 경계를 할 필요가 있다. 만약 운이 좋아 한줄에 여러 auto 변수를 초기화하는 방법을 사용했을 경우, 옆에 있는 다른 타입과 불일치되어 error C3538 을 뱉을 것이다. (위 참조)

언제 auto 키워드를 사용할까?

data 타입이 플랫폼/컴파일러에 따라 변경되는 경우

예를 들어

int nLength = strlen( "The auto keyword." );

위 코드는 32-bit 컴파일 환경에선 4 바이트일 것이고 64-bit 컴파일 환경에선 8 바이트일 것이다. 이것을 확실히 하려면 int 나 __int64 를 사용하는 대신 size_t 를 사용할 수도 있다. 근데 만약 size_t 가 정의되어 있지 않은 환경이거나 strlen 이 아예 다른 것을 리턴하는 경우1)라면 어떻게 될까? 이 경우 아래와 같이 auto 를 사용할 수 있다.

auto nLenth = strlen( "The auto keyword." );

data 타입 표현이 복잡한 경우

std::vector<std::string> Strings;  // string vector
 
for ( std::vector<std::string>::iterator iter = Strings.begin();
      iter != Strings.end();
      ++iter )
{
    std::cout << *iter << std::endl;
}

std::vector<std::string>::iterator iter 라고 표기하는 것은 복잡할 뿐 아니라 오타가 나기도 쉽다. typedef 를 사용할 수도 있겠지만 만약 이 표현을 단 한번만 사용하는 경우 이는 오히려 배보다 배꼽이 커지는 상황이다. 그냥 이럴 땐 아래와 같이 auto 를 사용하면 된다.

for ( auto iter = String.begin(); iter != Strings.end(); ++iter )
{
    std::cout << *iter << std::endl;
}

STL 을 지금껏 사용해 왔다면 constant iterator 에 대해서도 알고 있을 것이다. 위 예제 코드에 const_iterator 를 사용하고 싶을 수도 있다.

// using namespace std; 를 했다고 가정한다.
for ( vector<string>::const_iterator iter = Strings.begin(); iter != Strings.end(); ++iter )
{
    cout << *iter << endl;
}

보통 vector 의 element 를 수정하는 것을 방지하기 위해 begin 멤버함수를 const_iterator 로 받아 사용하는 경우가 많다.2) 그리고 const iterator 는 const_iterator 와 다르다.3)

따라서 이 모든 것을 auto 키워드와 결합하여 생각한 결과 표준 C++ STL 컨테이너에 cbegin, cend, crbegin, crend 멤버변수가 추가되었다. c 라는 prefix 는 constant 의 의미이며 항상 const_iterator 를 반환한다. 그리고 auto 버전으로 바꿔보면 아래와 같다.

for ( auto iter = Strings.cbegin(); iter != Strings.cend(); ++iter )
{ ... }

또 다른 예로 좀 더 복잡한 constant iterator 가 있다.

// using namespace std; 가 선언되었다고 가정한다.
map<vector<int>, string> mstr;
map<vector<int>, string>::const_iterator m_iter = mstr.cbegin();

iterator 대입 연산을 아래와 같이 간단하게 할 수 있다.

auto m_iter = mstr.cbegin();

복잡한 template 관련 변수 접근에는 auto 키워드를 사용함으로써 타이핑을 쉽게 함과 동시에 오타에 의한 에러를 미연에 방지할 수 있다.

Lambda 를 변수에 대입하는 경우

Trailing Return Type 을 정의하는 경우

2020/01/07 07:00

decltype 키워드

decltype 은 표현식을 이용하여 컴파일시간에 타입을 추론할 수 있게 해주는 키워드이다. 예를 들어

int nVariable1;
...
decltype( nVariable1 )   nVariable2;

nVariable2 는 int 타입으로 선언된다. 컴파일러는 nVariable1 의 타입이 int 인 것을 알고 있고 decltype( nVariable1 ) 을 int 로 번역한다. decltype 키워드는 typeid 가 아니다. typeid 는 type_info 구조체를 리턴하고 RTTI 가 활성화 되어 있어야 한다. typeid 는 타입 자체를 반환하는게 아니라 구조체를 반환하므로 decltype 은 이와는 전혀 관련이 없다. 게다가 decltype 은 컴파일 시점에 결정난다.

decltype 은 일반적으로 auto 키워드와 결합하여 사용된다. 예를 들어 아래와 같이 auto 변수를 선언했다 치자.

auto xVariable = SomeFunction();

이제 xVariable 의 타입을 X 라고 가정하고 더 이상의 함수호출 없이 X 타입의 또다른 변수를 어떻게 선언할 수 있을까?

아래중 어느 것이라고 생각하는가?

decltype( SomeFunc )   yVar;
decltype( SomeFunc() ) yVar;

첫번째 선언된 yVar 는 함수포인터가 될 것이다. 컴파일러는 어떠한 경고나 에러를 뱉지는 않겠지만 이건 우리가 원했던게 아니다. 그리고 두번째 선언된 yVar 는 X 타입으로 선언된다. 우리가 원했던 바 이지만 SomeFunc 함수의 반환타입을 알아내기 위해 함수의 정확한 파라메터를 꼭 알아야 한다는게 좀 께림칙하다.

따라서 추천시되는 접근법은 변수를 통해 직접적으로 추론하는 것이다.

decltype( xVariable )  yVar;

게다가 auto 키워드에서 봐왔듯이 template 의 타입을 직접 타이핑하는 것은 복잡할 뿐아니라 보기에도 거시기 하기 때문에 auto 를 사용하기로 했었다. 이와 비슷하게, decltype 은 template 과 같은 복잡한 표현식으로부터 타입을 추론할 수 있다.

decltype( Strings.begin() )     string_iterator;
decltype( mstr.begin()->second.get_allocator() )   under_alloc;

이 앞의 섹션에서 타입을 추론하기 위해 오른쪽 항에 반드시 초기화 표현이 동반되었던 auto 키워드와는 다르게 decltype 키워드는 이러한 초기화 없이 타입을 추론할 수 있게 해준다. 또, decltype( Strings.begin() ) 에서 실제 begin 함수가 호출되는 것이 아니다. 이 표현식에서 단지 타입을 추론할 뿐이다.

위의 두번째 예에서 mstr 은 std::map 오브젝트이다. 그리고 iterator 를 획득한 후에 map element 의 value 값으로 접근하고 마지막으로 allocator type 을 획득한다. 따라서 추론된 타입은 string 에 대한 std::allocator 이다.

이런 우스꽝스러운 표현도 가능하다.

decltype( 1 / 0 )  Infinite;             // "devide by zero" 임에도 불구하고 에러가 아니다. int 로 추론된다.
 
decltype( exit( 0 ) )  MyExitFunction(); // MyExitFunction 함수의 반환타입이 void 로 추론될 뿐 실제 exit( 0 ) 으로 인해 프로그램이 종료되지는 않는다.
2020/01/07 07:00

nullptr 키워드

null 포인터가 마침내 자체적인 키워드 형태의 폼을 가지게 되었다. 당연하지만 이것은 NULL 매크로나 숫자 0 과 거의 같다. 이제 managed code 와 마찬가지로 native C++ 에서도 nullptr 키워드를 사용할 수 있다. 만약 mixed mode 코드에서 native 버전 null 포인터를 사용하고자 하는 경우 _ _nullptr 을 쓰면 되고 nullptr 은 managed 버전이 된다. 헌데 nullptr 이 있는데 그럴 필요까지 있을까 싶다.

아래와 같이 일반적으로 NULL 과 비슷하게 사용할 수 있다.

void* pBuffer = nullptr;
 
...
 
if ( pBuffer == nullptr )
{ ... }
 
void SomeClass::SomeFunction()
{
    if ( this != nullptr )
    { ... }
}

nullptr 이 필요한 이유

이전까지는 NULL 이나 상수 0 을 사용하여 nullptr 의 의미로 사용하였다. 그러나 NULL 이나 0 을 함수에 파라메터로 넘기는 경우 int 타입으로 추론되어 문제를 유발하는 경우가 있다.

// using namespace std; 가 선언되었다고 가정
 
void func( int a )
{
    cout << "func - int" << endl;
}
 
void func( double* p )
{
    cout << "func - double*" << endl;
}
 
void main()
{
    func( static_cast<double*>( 0 ) ); // double 로 추론
    func( 0 );     // int 로 추론
    func( NULL );  // int 로 추론
}

위 예에서 두번째, 세번째 함수 호출에 nullptr 을 넣음으로써 double* 버전의 func 을 함수를 호출하려 했으나 int 로 추론되는 바람에 실패하였음을 알 수 있다. 이것을 nullptr 키워드를 사용하면 정상적으로 double* 버전의 func 함수가 호출된다.

func( nullptr );   // 정상적으로 double* 버전의 func 함수가 호출된다.

가능한 예들

char* ch = nullptr;
sizeof( nullptr );    // 크기는 4 로 나온다.
typeid( nullptr );
throw nullptr;

불가능한 예들

int n = nullptr;    // 포인터형이므로 int 에 대입 불가
 
int n2 = 0;
if ( n2 == nullptr );
 
if ( nullptr );
 
if ( nullptr == 0 );
 
nullptr = 0;
 
nullptr + 2;
2020/01/07 07:00

static_assert 키워드

static_assert 는 특정 조건을 컴파일시간에 검증할 수 있게 한다. 여기 문법을 보자.

static_assert( expression, message )

expression 은 컴파일시간에 판단할 수 있는 상수constant 표현식이어야 한다. non-templated 에 대해서 컴파일러는 즉각적으로 체크하며, templated 된 것은 class 가 초기화될 때 체크한다.

expression 이 true 라면 필요한 조건이 충족되었다고 판단하여 아무일도 일어나지 않늗나. 만약 false 라면 컴파일러는 error C2338 를 뱉으며 message 를 출력한다. 예를 들어

static_assert( 10 == 9, "Nine is not equal to ten" );

요건 명백히 false 이므로 컴파일러는 다음과 같은 에러를 뱉는다.

error C2338: Nine is not equal to ten

좀 더 의미있는 예를 써보자면 아래와 같은게 있을 수 있다.

static_assert( sizeof(void*) == 4, "This code should only be compiled as 32-bit." );

포인터의 사이즈 (어느 사이즈던 상관없이) 는 해당 platform 과 같기에 위 예에서 처럼 32 bit 환경이 아닐 경우 에러를 뱉어내게끔 할 수 있다.

조금 내용을 덧붙이자면 이전 컴파일러에서 _STATIC_ASSERT 라는 매크로로 이 기능을 대체했다. 이 매크로의 원리는 expression 표현식의 결과를 배열선언의 size 로 활용하여 컴파일러가 에러를 출력하게하는 방식이다. expression 이 true 이라면 1 이므로 1 개의 배열이 만들어지고 false 라면 0 개의 배열을 선언하여 error 를 출력하게 된다. 하지만 error 의 내용은 비 직관적4)이었다. static_assert 의 존재로 더이상 이런 혼동은 없어졌다.

2020/01/07 07:00

Lambda Expression

이번 C++0x 에 추가된 가장 임팩트있느 기능 중 하나이다. 유용하고 흥미로운건 사실이지만 복잡하기도 하다. 일단 기본적인 문법과 예를 들어 설명을 시작하겠다. 처음 시작하는 아래 몇개의 Lambda 코드샘플들이 쓸모없어 보일수도 있지만 분명한 것은 Lambda 는 그 간결함에 비해 매우 강력하다는 것이다.

시작하기 전 이걸 먼저 기억하자.

Lambda 는 지역적으로 선언된 함수와도 같다. 표현식이 사용되거나 함수가 호출될 수 있는 곳에서는 어디든지 Lambda 를 사용할 수 있다.

일단 가장 기본적인 Lambda 를 보자.

[]{};

위 코드는 C++0x 에서 완벽하게 작동되는 코드이다. [] 는 Lambda-introducer 라고 부르며 컴파일러에게 이후에 따라오는 expression/code 가 lambda 임을 알려준다. {} 는 Lambda 의 정의부이며 일반 함수의 그것과 같다. 위에 정의한 lambda 는 어떠한 argument 도 없고 어떠한 값도 return 하지 않으며 아무런 동작도 하지 않는다.

이제 확장해보자.

double PI = []{ return 3.14159; }();        // 뒤에 () 를 추가함으로써 함수호출 형태를 갖는다.
double PI = [](){ return 3.14159; }();      // 앞에 () 를 추가하였으며 아무 argument 도 갖지 않는다.
double PI = [](void){ return 3.14159; }();  // 바로 위와 같은 의미

함수 호출을 위해 뒤에 괄호가 붙어야 하며, 파라메터가 없다면 앞에 괄호는 생략 가능하다. C++ 표준 committee 는 Lambda 가 덜 복잡하길 원하며 그래서 생략 가능토록 디자인하였다.

이제 파라메터를 사용하는 경우를 보자.

bool is_even = []( int n ){ return n % 2 == 0; }( 41 );

첫번째 괄호 ( int n ) 는 parameter 를 나타낸다. 그리고 뒤에 괄호 ( 41 ) 는 Lambda 호출을 위한 실제 argument 를 의미한다. 위 본문은 파라메터 n 이 짝수인지 여부를 반환한다. 이제 max, min 를 구하는 Lambda 를 구현해보자.

int nMax = []( int n1, int n2 ) {
    return ( n1 > n2 ) ? n1 : n2;
} ( 56, 11 );
 
int nMin = []( int n1, int n2 ) {
    return ( n1 < n2 ) ? n1 : n2;
} ( 984, 658 );

위와 같이 여러개의 파라메터를 사용하는 Lambda 함수를 한줄로 표현할 수 있게 되었다. 두개의 파라메터를 받아서 하나를 리턴한다. 비슷하게 여러 파라메터를 받게할 수도 있다.

이제 몇가지 의문사항이 생길텐데..

  • 리턴타입은 int 만 가능한가?
  • lambda 가 하나의 return statement 로 표현 불가능한 경우엔? 예를 들어 return 이전에 몇개의 선행 라인이 있어야 하는 경우.
  • lambda 가 value 를 출력하거나 하는 등의 다른 행위가 필요한 경우엔?
  • 정의된 lambda 의 reference 를 저장하여 나중에 재사용할 수 있는가?
  • lambda 가 다른 lambda 나 function 을 호출할 수 있는가?
  • lambda 는 local-function 으로써 저장된다. 그렇다면 function 을 넘나들며 사용할 수 있는가?
  • lambda 는 그 자신이 정의된 영역의 변수에 접근이 가능하며 수정도 가능한가?
  • default arguments 를 지원하나?
  • function pointer 와 functor 와 뭐가 다른가?

이 질문들에 답하기 전에 Lambda Expression 문법에 대한 설명이 있는 아래 이미지를 먼저 보자.

25ec_25b0_25b8_25ec_25a1_25b0.jpg

리턴타입은 int 만 가능한가?

→ operator 다음에 return 타입을 정의할 수 있다. 예를 들어

pi = []()->double{ return 3.14159; }();

위 lambda 는 return statement 한줄로 이루어져 있기 때문에 명시적으로 return 타입을 쓸 필요는 없다. 따라서 →double 는 생략이 가능하다.

이번엔 이 예를 보자.

int nAbs = [] (int n1) -> int
{
    if ( n1 < 0 )
        return -n1;
    else
        return n1;
}( -109 );

위 lambda 는 하나 이상의 statement 를 이용하였기에 반드시 명시적으로 return 타입을 써야 한다. 만약 →int 를 생략하면 아래의 에러가 뜬다.

error 3499: a lambda that has been specified to have a void return type cannot return a value

컴파일러는 lambda 본문의 내용이 return statement 하나루 이루어져 있지 않다면 기본적으로 void 리턴형으로 가정한다. 따라서 반드시 명시적으로 써 주어야 한다.

아래와 같이 리턴 타입은 어느것이든 될 수 있다.

[]() -> int* {}
[]() -> std::vector<int>::const_iterator& {}
[]( int x ) -> decltype( x ) {}     // x 로부터 타입을 추론

반면 배열이나 auto 를 리턴타입으로 가질 수 없다.

[]() -> float[] {};       // error C2090: function returns array
[]() -> auto {};          // error C3558: 'auto': a lambda return type cannot contain 'auto'

물론 lambda 에 의해 반환된 값을 auto 에 대입할 수는 있다.

auto pi = [] { return 3.14159; } ();
auto nSum = [] ( int n1, int n2, int n3 ) { return n1 + n2 + n3; } ( 10, 20, 70 );
auto xVal = [] ( float x ) -> float
{
    float t
    t = x * x / 2.0f;
    return t;
} ( 44 );

만약 파라메터가 없기에 괄호() 를 생략한 상태에서 명시적 return 타입을 쓴다면 에러가 발생한다.

[] -> double { return 3.14159; } ();   // 에러!! []() -> double {...} 와 같이 반드시 괄호를 써줘야 함

lambda 가 하나의 return statement 로 표현 불가능한 경우엔?

위 설명만으로 일반적인 함수가 가질 수 있는 표현들을 lambda 에 담기에 충분하다. lambda 는 function/method 가 가질 수 있는 어떠한 것이던 포함할 수 있다. local/static 변수, 다른 함수를 호출하고 메모리를 할당하고 다른 lambda 를 호출하는 것 까지! 아래의 코드는 터무니 없어보여도 분명 유효한 코드이다.

[]()
{
    static int stat = 99;
 
    class TestClass
    {
    public:
        int member;
    };
 
    TestClass test;
    test.member = labs( -100 );
 
    int* ptr = [] ( int n1 ) -> int*
    {
        int* p = new int;
        *p = n1;
        return p;
    } ( test.member );
 
    delete ptr;
};

정의된 lambda 의 reference 를 저장하여 나중에 재사용할 수 있는가? lambda 는 local-function 으로써 저장된다. 그렇다면 function 들 사이에서 사용될 수 있는가?

짝수인지 아닌지를 판별하는 lambda 를 정의해보자. auto 키워드를 쓰면 lambda 를 변수로 저장할 수 있다. 그리고 이 변수로 lambda 를 호출할 수 있다. lambda 의 타입은 나중에 논의해보기로 한다.

auto IsEven = [] ( int n ) -> bool
{
    if ( n % 2 == 0 )
        return true;
    else
        return false;
}; // lambda 를 호출한 것이 아니다. 괄호() 가 없는 것을 보면 알 수 있듯이..

추론할 수 있듯이 위 lambda 는 인자를 받아서 bool 타입을 리턴한다. 그리고 중요한 것은 lambda 를 호출한 것은 아니라 정의만 했다는 것이다. 만약 괄호() 와 인자를 저 위 코드에 추가했더라면 auto 타입은 bool 이 되었을 것이다. (lambda 타입이 아닌!) 이제 지역적으로 정의된 함수와 마찬가지로 나중에 호출할 수 있다.

IsEven( 20 );
 
if ( !IsEven( 45 ) )
    std::cout << "45 is not even";

위에 있는 IsEven 정의부는 그 아래 두번의 호출한 부분과 지역적으로 같은 곳에 있다. 만약 다른 함수에서 호출하고자 하면 어떻게 해야할까? 여러가지 접근법이 있겠다. 예를 들어 local 또는 class 레벨의 변수에 저장하고 다른 함수의 인자로 넘긴다던가 (function pointer 같이) 또는 global 영역에 함수를 정의하는 방법도 있다. 아직 lambda 의 변수타입을 논의하지 않았기 때문에 전자의 경우는 나중에 얘기하기로 하고 일단 후자의 경우 (global 에 정의) 를 먼저 살펴보자.

// lambda 의 리턴타입은 bool 이고, lambda 는 IsEven 에 auto 로써 저장된다.
auto IsEven = [] ( int n ) -> bool
{
    if ( n % 2 == 0 ) return true;
    else return false;
}
 
void AnotherFunction()
{
    IsEven( 10 );
}
 
int main()
{
    AnotherFunction();
    IsEven( 10 );
}

auto 키워드는 local 또는 global 영역에서만 동작하기에 위 예시처럼 global 변수에 lambda 를 저장할 수 있다. 나중에 클래스 변수에 저장하기 위해서는 lambda 의 정확한 타입을 알 필요가 있겠다.

앞서 언급한 대로 lambda 는 거의 일반 function 처럼 동작한다. 따라서 변수를 출력한다거나 하는 등의 행위도 역시 할 수 있다.

int main()
{
    using namespace std;
 
    auto DisplayIfEven = [] ( int n ) -> void
    {
        if ( n % 2 == 0 )
            std::cout << "Number is even\n";
        else
            std::cout << "Number is odd\n";
    };
 
    cout << "Calling lambda...";
 
    DisplayIfEven( 40 );
}

하나 중요한 것은 지역적으로 정의된 (locally-defined) lambda 는 상위 scope 에 정의된 namespace resolution 을 얻지 못한다는 것이다. 따라서 위 예에서 DisplayIfEven 함수 안에서 std namespace 가 유효하지 않기에 명시적으로 std:: 를 써줘야 한다.

lambda 가 다른 lambda 나 function 을 호출할 수 있는가?

function 과 마찬가지로 가능하다.

lambda 가 default arguments 를 지원하는가?

지원하지 않는다.

lambda 가 그 자신이 정의되거나 호출된 곳에 있는 변수로 접근이 가능한가? 변수를 수정할 수 있는가? function pointer 와 function objects (functors) 와 다른점은?

이제 지금까지 언급하지 않았던 Capture Specification 에 대해 논의해보자.

Lambda 는 다음중 하나가 될 수 있다.

  • Stateful
  • Stateless

state 는 상위 영역 (이하 upper-scope) 에 있는 변수를 capture 하는 방식에 대한 정의이다. 나는 그것들을 다음과 같은 카테고리로 분류한다.

  1. upper-scope 로부터의 어떠한 변수도 접근하지 않는다. 지금까지 설명해온 방식이 여기에 해당한다.
  2. upper-scope 변수에 read-only 모드로 변수에 접근한다.
  3. 변수가 lambda 로 복사되어지고 (같은 이름으로) 이 복사본을 수정할 수 있다. 함수 호출에 call-by-value 와 같은 원리이다.
  4. upper-scope 변수에 full accessibility 로 접근한다. 물론 수정도 가능하다.

이 네가지 카테고리는 다음의 C++ 특성들에서 따온 것이다.

  1. 변수는 private 이고, 따라서 외부에서 접근할 수 없다.
  2. const 멤버 함수에선 변수를 수정할 수 없다.
  3. 변수가 함수에 넘겨질 때 passed-by-value 로 넘어간다.
  4. 함수에 passed-by-reference 로 넘어갈 경우 full accessibility 를 가진다.

이제 capture 해보자. 위에서 언급한 capture specification 은 [] 안에서 정의된다. 아래의 문법은 capture-specification 을 나타낸다.

  • [] - 아무것도 capture 하지 않는다.
  • [=] - 모든 것을 value 로써 capture 한다.
  • [&] - 모든 것을 reference 로써 capture 한다.
  • [var] - var 를 value 로써 capture 한다. 이 외 다른 것들은 아무것도 capture 하지 않는다.
  • [&var] - var 를 reference 로써 capture 한다. 이 외 다른 것들은 아무것도 capture 하지 않는다.

예1:

int a = 10, b = 20, c = 30;
 
[a] (void)     // 'a' 만 value 로써 capture 한다.
{
    std::cout << "Value of a=" << a << std::endl;
 
    // 수정할 수 없다.
    a++;   // error C3491: 'a': a by-value capture cannot be modified in a non-mutable lambda
 
    // 다른 변수엔 접근할 수 없다.
    std::cout << b << c;  // error C3493: 'b' cannot be implicitly captured because no default capture mode has been specified
}();

예2:

auto Average = [=] () -> float        // '=' 는 모든 것을 value 로써 capture 한다는 뜻
{
    return ( a + b + c ) / 3.0f;  // 모든걸 value 로써 capture 하지만 수정할 수는 없다.
}
 
float x = Average();

예3:

auto ResetAll = [&] () -> void
{
    a = b = c = 0; // reference 로써 capture 했기에 수정 가능하다.
};
 
ResetAll();

= 는 by-value 를 선언하는 것이고, & 는 by-reference 를 선언하는 것이다. 좀 더 살펴보자. 줄여쓰기 위해 앞으로는 auto 변수에 lambda 를 저장하지 않고 바로 호출할 것이다.

예4:

// a, b 를 by value 로 capture 한다.
// 그리고 파라메터가 없으므로 () 생략 가능하다.
int nSum = [a,b]
{
    return a + b;
} ();
 
std::cout << "Sum: " << nSum;

위에서 보듯이 여러 capture specification 을 동시에 사용할 수 있다. 이걸 응용해서 a, b, c 를 합하여 nSum 에 대입하는 예를 작성해 보겠다.

예5:

// 모든걸 by-value 로 capture 한 후, nSum 만 by-reference 로 capture 한다.
[=, &nSum]
{
    nSum = a + b + c;
} ();

위 예에서 = 로 모든걸 by-value 로 capture 한 부분은 default capture mode 에 해당하고 &nSum 는 그 위에 override 를 의미한다. default capture mode 는 다른 capture 이전에 와야 한다. 즉, = 또는 & 와 같이 모든 변수를 대상으로 하는 선언이 단일 변수를 대상으로 한 선언보다 앞에 와야 한다. (제일 앞에 와야 한다는 얘기다) 따라서 아래 예는 에러를 뱉는다.

[&nSum, =] {};  // 에러!!
[a,b,c,&] {};

예를 몇 개 더 보자.

[&, b] {};             // (1) 전부 by-reference 지만 'b' 만 by-value
[=, &b] {};            // (2) 전부 by-value 지만 'b' 만 by-reference
[b, c, &nSum] {};      // (3) b, c, 는 by-value, c 는 by-reference, 다른것은 capture 하지 않는다.
[=] ( int a ) {};      // (4) 전부 by-value 이고 원래의 'a' 변수는 파라메터 'a' 에 의해 가려진다! 문법상 오류가 아니므로 주의해서 사용하자.
[&, a, c, nSum] {};    // 결과적으로 (2) 와 같다.
[b, &a, &c, &nSum] {}; // 결과적으로 (1) 과 같다.
[=, &] {};             // error C3409: empty attribute block is not allowed
[&nSum, =] {};         // error
[a, c, b, &] {};       // error

지금까지 특정 변수가 capture 되는 여러가지 경우를 보아 왔다. 특정 변수가 capture 되지 않도록 하는 것, by-value 로 하되 const 로써 수정되지 않도록 capture 하는 것, by-reference 로 capture 하는 것 들을 보아왔다. 이것들은 각각 (위에서 열거한 내용) 1, 2, 4 번에 해당하는 것들이다. 이제 마지막으로 2번에 해당하는 call-by-value 모드를 보자.

mutable 키워드

파라메터를 넣는 괄호 바로 다음에 mutable 이라는 키워드를 지정할 수 있다. 이 키워드가 있다면 by-value 로 capture 하는데다 수정까지 할 수 있다. mutable 키워드를 쓰지 않으면 기본적으로 by-value 는 const 속성을 가진다. 즉 capture 해온 변수를 lambda 안에서 수정할 수 없다는 뜻이다. mutable 을 지정해 줌으로써 컴파일러에게 capture 해온 복사본 변수를 수정할 수 있게 해달라고 부탁할 수 있다. 그리고 by-value 로 capture 한 변수들 중 선택적으로 const, non-const 를 따로 지정할 수는 없다. 이렇게 하고 싶다면 간단하게 그냥 파라메터로 넘기는 방법을 쓰는게 좋다.

예:

int x = 0, y = 0, z = 0;
 
[=] () mutable -> void  // mutable 을 지정할 경우 괄호() 가 필수적이다.
{
    x++;  // mutable 이 지정되었기에 by-value 로 capture 해온 변수를 수정 가능하다.
} ();
// 하지만 x 는 여전히 0 이다.

위에서 lambda 호출이 끝난 후에도 x 의 값은 여전히 0이다. function 호출의 call-by-value 와 마찬가지로 lambda 안에서 x 는 복사본으로 취급되기 때문이다.

lambda 가 function-pointer 나 functor 와 다른점은?

funtion-pointer 는 자체 상태state 를 가질 수 없지만 lambda 는 상태를 가질 수 있다. by-reference 로 capture 하면 lambda 는 호출간 상태를 유지할 수 있다. 이것은 function 으로썬 할 수 없고 function-pointer 는 타입에 안전하지 않고 에러를 유발하기 쉽다. 그리고 calling-convention 과 복잡한 문법을 필요로 한다.

functor 는 물론 상태를 매우 잘 유지할 수 있다. 그러나 아주 사소하고 작은 코드조각을 위해서라도 class 를 작성하고 그 안에 변수를 넣고 operator () 를 정의해야 한다는 부담이 있다. 그리고 중요한 것은 이 functor 를 다른 함수에 사용하기 위해선 현재 함수의 바깥에 정의해두어야 한다. 이것은 코드의 흐름 (역주:가독성을 말하는 듯) 을 깨버린다.

lambda 의 타입은 무엇인가?

lambda 는 사실상 class 이다. 이 lambda 를 function class 오브젝트에 저장할 수 있다. std::tr1 네임스페이스에 이것이 정의되어 있다. 예를 보자.

#include <functional>
 
...
 
std::function<bool(int)> IsEven = [] ( int n ) -> bool
{
    return n % 2 == 0;
};
IsEven( 23 );

<bool(int)> 는 function 클래스의 template 파라메터이다. 대충 보는 바와같이 bool 형을 리턴하고 int 형 인자를 받는 함수라는 것을 의미한다. lambda 를 function object 로써 (functor) 저장할 때 타입캐스팅에 유의해야 한다. 그렇지 않으면 컴파일러가 에러나 경고를 뱉기 때문이다. 그러나 지금까지 보아왔듯이 auto 키워드를 이용하면 편하게 사용할 수 있었다.

하지만! 반드시 function 타입을 정확하게 칭해야 할 때가 있다. 이 function object 를 함수의 인자로써 넘길때이다. 예를 보자.

using namespace std;
 
void TakeLambda( function<void(int)> lambda ) // 여기서 auto 타입을 사용할 수 없다.
{
    lambda( 32 );
}
 
TakeLambda( DisplayIfEven ); // 위에서 정의했던 lambda 를 인자로 넣어 호출

DisplayIfEven 이라는 lambda 는 int 타입 파라메터를 받고 아무것도 return 하지 않는다. TakeLambda 함수는 이 function object 를 파라메터로 받으며, 함수 내에서 lambda 를 호출한다.

C++ 에서 lambda 는 어떤 의미인가?

lambda 는 많은 STL 함수들 (특히 function-pointer 나 function-object 를 필요로 하는) 에게 있어서 매우 유용하다. 간단히 말해 lambda 는 callback 함수가 필요한 경우 유용하게 사용될 수 있다. 여기서 STL 함수들에 대해 다루진 않겠지만 lambda 의 간략하고 이해하기 쉬운 형태에 대해서는 소개하겠다. non-STL 를 사용한 예는 일단 불필요하고 모양새가 이상하다. 그러나 여기서 그에 대한 해결책을 소개하겠다.

예를 들어 아래의 함수는 function 이 인자로써 필요하다. 함수 내에서 인자로 넘어온 파라메터로 다시 함수를 호출한다. int 형 인자를 받고 void 를 return 하는 형태의 function-pointer, function-object, 또는 lambda 중 어느것이든 아래 함수의 인자로써 사용될 수 있다.

void CallbackSomething( int nNumber, function<void(int)> callback_function )
{
    callback_function( nNumber );
}

그리고 이 CallbackSomething 을 호출하는 세가지 다른 유형을 보여주겠다.

// 그냥 함수
void IsEven( int n )
{
    std::cout << ( ( n % 2 == 0 ) ? "Yes" : "No" );
}
 
// operator () 를 오버라이드한 class. 일명 functor
class Callback
{
public:
    void operator() ( int n )
    {
        if ( n < 10 )
            std::cout << "Less than 10";
        else
            std::cout << "More than 10";
    }
};
 
int main()
{
    // function-pointer 사용
    CallbackSomething( 10, IsEven );
 
    // function-object 사용
    CallbackSomething( 23, Callback() );
 
    // lambda 사용
    CallbackSomething( 59, [] ( int n ) { std::cout << "Half:" << n / 2; } );
}

이제 N 보다 큰 숫자인 경우에만 출력을 하는 Callback 이란 함수를 만들어보자.

class Callback
{
    /*const*/ int Predicate;
 
public:
    Callback( int nPredicate ) : Predicate( nPredicate ) {}
 
    void operator() ( int n )
    {
        if ( n < Predicate )
            std::cout << "Less than " << Predicate;
        else
            std::cout << "More than " << Predicate;
    }
};
 
// function object 사용
CallbackSomething( 23, Callback( 24 ) );
 
// 좀 다른 방법
Callback obj( 99 );
CallbackSomething( 44, obj );

이걸 호출하기 위해 단지 기준되는 숫자를 가지고 생성자를 인자로 넣어주면 된다. 원본 CallbackSomething 함수는 바꿀 필요가 없는 것이다.

이 방법에서 Callback 이란 클래스에 현재 state 를 유지하는 기능을 추가하였다. 이 class 인스턴스가 살아있는 한 그 state 역시 계속 유지된다. 그러므로 만약 이 인스턴스를 가지고 여러 CallbackSomething 함수에 사용했더라도 같은 state 를 사용할 것이다. 알겠지만 이것은 function-pointer 에서는 할 수 없는 것이다. 물론 또 다른 인자를 추가하고 받아들이게끔 하면 가능은 하지만 별로 예쁜 모습은 아닐 것이다. 만약 특정 함수에서 내부에서 호출 가능한 어떠한 것 (function-pointer or functor 등) 을 파라메터로 요구되는 경우 그 타입을 명확히 지정해야 하고 반드시 그 타입의 것을 인자로 넣어야 한다. 그리고 function-pointer 는 state 를 가질 수 없고 보통 이런 시나리오에서 유용하지 않다.

그럼 lambda 는 어떤가? 이전에 언급했던대로 lambda 는 capture specification 을 통해 state 를 가질 수 있다. 그래서 lambda 는 이러한 유동적인 state 기능을 이용해서 위 시나리오 해결이 가능하다. 여기 예시를 보자.

int Predicate = 40;
 
// state 를 가지는 lambda
auto stateful = [Predicate] ( int n )
{
    if ( n < Predicate )
        std::cout << "Less than " << Predicate:
    else
        std::cout << "More than " << Predicate;
};
 
CallbackSomething( 59, stateful );  // more than 40 을 확인
 
Predicate = 1000;
CallbackSomething( 100, stateful ); // lambda 에서 Predicate 는 변하지 않고 여전히 more than 40 을 확인한다.

함수 안에서 지역적으로 정의된 stateful 한 lambda 는 function-object (functor) 보다 간결하고 function-pointer 보다 깔끔하다. 그리고 state 로 가진다.

위 예에서 첫번째 함수 호출에서 “More than 40” 이 출력된다. 그리고 두번째 호출에서도 여전히 같은 결과가 출력된다. 중간에 Predicate 에 1000 을 대입하여 state 를 바꾸길 의도했지만 그렇지 못했다. by-value 로 capture 했기 때문에 lambda 안에서는 복사본이 생성되었기 때문이다. 따라서 외부의 state 수정이 lambda 에도 영향을 미치게 하려면 by-reference 로 capture 해야 한다.

auto stateful = [&Predicate] ( int n )  // by-reference 로 capture

만약 functor 로 이를 구현하려면 SetPredicate 등의 멤버함수를 class 에 추가하여 해결 가능하겠다.

STL 과의 조합

for_each STL 함수는 컨테이너 안에 특정 range 에 해당하는 element 들에게 특정 함수를 적용한다. template 을 이용하기때문에 인자로써 어떤 타입이라도 대입될 수 있다. 이제 lambda 로 예를 들어 설명해보자. 간략화 하기 위해 list 나 vector 대신 순수 array 를 사용하겠다.

using namespace std;
 
int Array[ 10 ] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
 
for_each( Array, &Array[ 10 ], IsEven );
 
for_each( Array, Array + 10, [] ( int n ) { std::cout << n << std::endl; } );

첫번째로 IsEven 함수를 호출하고 두번째로 for_each 안에 정의된 lambda 를 호출한다. 원소가 10개이기 때문에 각각은 함수를 10번씩 호출한다. for_each 두번째 파라메터가 같은 의미라는건 구지 말하지 않겠다. (근데 말하고 말았네..)

이건 for_each 와 lambda 를 이용하여 값을 출력하는 매우 간단한 예이다. 구지 별도의 function 이나 class 를 작성하지도 않았다. 확실히 lambda 는 부가적인 일을 위해 쉽게 확장 가능하다. 솟수만 출력한다던가 합을 계산하거나 특정 영역range 의 원소element 를 수정한다던가 등의 일 말이다.

lambda 인자를 수정하기

음, 물론 가능하다. 좀 더 말해보자면, 지금까지는 변수를 by-reference 로 capture 하여 수정하는것은 말했지만 lambda 의 인자argument 를 수정하는 것은 말하지 않았다. 지금까지 그 필요성을 못 느꼈기 때문이다. 어쨌든 가능하고 그렇게 하려면 lambda 의 파라메터를 reference 로써 (혹은 pointer) 선언하면 된다.

// lambda 의 파라메터 n 이 reference 로 선언됨
for_each( Array, Array + 10, [] ( int &n ) { n *= 4; } );

위 for_each 는 배열의 각 원소element 들에 4를 곱한다.

lambda 를 for_each 와 함께 사용하는 방법에 대해 설명했던것과 같이 <algorithm> 에 있는 다른 함수들, transform, generate, remove_if 등과도 역시 조합하여 사용 가능하다. lambda 는 STL 알고리즘에 한정되어 있지 않고 function-object 가 필요로 하는 곳에 효과적으로 활용이 가능하다. 정당한 숫자와 인자 타입만 지정해주고 혹시 인자 수정이 필요한지와 같은 것만 주의해주면 된다. 이 article 이 STL 이나 template 을 다루지는 않기에 더 이상 언급하진 않겠다.

lambda 는 function-pointer 와 같이 사용할 수 없다.

좀 헷갈리고 실망스러울 수 있지만 사실이다. function-pointer 를 필요로 하는 함수에 lambda 를 대신 사용할 수 없다. 간단한 예를 들어 내가 무얼 말하려는지 보여주겠다.

// int 를 인자로 받는 함수
typedef void (*DISPLAY_ROUTINE)(int);
 
// 함수 포인터를 받는 함수
void CalculateSum( int a, int b, DISPLAY_ROUTINE pfDisplayRoutine )
{
    pfDisplayRoutine( a + b );
}

CalculateSum 은 DISPLAY_ROUTINE 이라는 함수 포인터를 받는다. 아래 코드는 동작할 것이다.

void Print( int x )
{
    std::cout << "Sum is: " << x;
}
 
int main()
{
    CalculateSum( 500, 300, Print );
}

그러나 아래 코드는 잘못되었다.

CalculateSum( 10, 20, [] ( int n ) { std::cout << "Sum is: " << n; } );
// C2664: 'CalculateSum' : cannot convert parameter 3 from '`anonymous-namespace'::<lambda1>' to 'DISPLAY_ROUTINE'

왜일까? lambda 는 객체지향object-oriented 이고 사실은 class 이기 때문이다. 컴파일러는 내부적으로 lambda 를 위해 class 모델을 생성한다. 그리고 내부적으로 operator () 를 오버로드하게끔 생성하고 특정 data member 들을 생성한다. (이건 capture-specification 과 mutable-specification 으로 추측할 수 있다.) 물론 이 class 오브젝트가 function-pointer 로 변형될 수 없는 것이다.

이전의 예를 실행 가능하게 하는 방법은?

글쎄.. std::function 이라는 똑똑한 class 덕분에 위의 CallbackSomething 이 (함수포인터가 아닌) 함수를 인자로써 받을 수 있었다. for_each 는 std::function 을 취하지 않지만 template 을 사용한다. 그리고 내부적으로는 세번째 파라메터에 괄호를 붙여 호출하는 형태를 취한다.

template <class Iterator, class Function>
void for_each( Iterator first, Iterator, Function func )
{
    // 이 부분은 first 부터 두번째 파라메터까지 loop 하는 로직이라고 가정한다.
    // func 은 일반 함수이거나 operator () 를 가지는 클래스일 수 있다.
    func( first );
}

find, count_if 등의 다른 STL 함수들도 위와 비슷하게 동작하고 따라서 function-pointer, function-object, lambda 의 경우 모두 동작할 것이다.

따라서 lambda 를 SetTimer, EnumFontFamilies 등의 API 의 인자로써 사용하고자 한다면 그만 두는게 좋다. 강제 타입캐스팅을 한다고 하더라도 동작하지 않을 것이기 때문이다. 크래시가 날 뿐이다.

2013/01/16 16:04 · z3moon

Trailing Return Type (리턴 타입 추적하기)

간단한 예로 시작해보자. 아래 long 타입을 리턴하는 함수가 있다. 이건 lambda 가 아니라 그냥 함수이다.

auto GetCPUSpeedInHertz() -> long
{
    return 1234567890;
}

보는바와 같이 long 타입을 리턴한다. Trailing Return Type 이라는 새로운 문법을 사용한 예이다. 왼쪽에 있는 auto 키워드는 자리선점의 의미이다. 실제 타입은 → 이후에 표시된 것을 따른다. 예를 한가지 더 보자.

auto GetPI() -> decltype( 3.14 )
{
    return 3.14159;
}

리턴타입이 decltype 으로부터 추론되었다. (위 decltype 키워드를 한번 참조) 위 두가지 예를 보아도 이걸 사용할 필요성을 느낄 수 없을 것이다.

그럼 어디서 쓸만한 걸까?

template 함수를 생각해보자.

template <typename FirstType, typename SecondType>
/*UnknownReturnType*/ AddThem( FirstType t1, SecondType t2 )
{
    return t1 + t2;
}

두 특정 값을 더하여 리턴하는 내용이다. 이제 int 와 double 을 각각 인자로 넣는다면 리턴타입은 어떻게 될까? 아마 double 이라 대답할 것이다. 근데 이것이 SecondType 이 함수의 리턴타입이 되어야 한다는 것을 의미할까?

template <typename FirstType, typename SecondType>
SecondType AddThem( FirstType t1, SecondType t2 );

사실 이걸 단언할 수는 없다. 이 함수가 어떤 타입을 왼쪽 혹은 오른쪽에 적용하여 호출될 지는 아무도 모르기 때문이다. 심지어 더 높은 고 수준의 타입이 사용될 수도 있다. 예를 들어

AddThem( 10.0, 'A' );
AddThem( "CodeProject", ".com" );
AddThem( "C++", 'X' );
AddThem( vector_object, list_object );

또한 AddThem 함수 안에 있는 operator + 는 어쩌면 overloaded 된 다른 멤버함수를 호출할 지도 모른다. 그리고 이 멤버함수는 지금껏 언급되지 않은 또다른 세번째 타입을 리턴할 지도 모른다. 이제 해결책은 아래와 같이 사용하는 것이다.

template <typename FirstType, typename SecondType>
auto AddThem( FirstType t1, SecondType t2 ) -> decltype( t1 + t2 )
{
    return t1 + t2;
}

decltype 설명에서 언급했듯이 타입은 표현식으로 결정이 된다. t1 + t2 에 대한 타입이 결정되는 것이다. 만약 컴파일러가 타입 업그레이드5)가 가능하다면 그렇게 할 것이다. 만약 타입이 class 의 일종이고 overloaded 된 operator + 이 호출된다면 operator + 의 리턴타입이 최종 리턴타입으로 결정될 것이다. 리턴타입이 native (built-in을 뜻함?) 가 아니고 overloaded 된 함수도 찾을 수 없다면 에러를 뱉을 것이다. 이 타입추론이 실제 template 함수를 특정 데이터타입과 함께 인스턴스화 될 때 정해진다는 사실은 매우 중요하다. 이 이전에 컴파일러는 어떠한 체크도 하지 않을 것이다.

2013/01/16 16:05 · z3moon

R-Value reference

일단 call-by-value 와 call-by-reference 를 알고 constant reference 역시 안다고 가정하겠다. 그리고 더 나아가 L-value 와 R-value 의 의미 역시 알 거라고 가정하겠다. 이제 R-value reference 를 사용하는 예를 보자.

class Simple {};
 
Simple GetSimple()
{
    return Simple();
}
 
void SetSimple( const Simple & )
{
}
 
int main()
{
    SetSimple( GetSimple() );
}

여기서 GetSimple 함수가 Simple 오브젝트를 리턴하고 SetSimple 함수에서 reference 로써 Simple 오브젝트를 받는다. SetSimple 함수의 인자로써 GetSimple 을 사용한다. GetSimple 리턴으로 반환된 오브젝트는 SetSimple 함수가 종료되는 즉시 파괴될 것이다. (반환된 임시 오브젝트이므로..) 이제 SetSimple 함수를 확장해보자.

void SetSimple( const Simple &rSimple )
{
    // Simple 로부터 오브젝트 생성
    Simple object( rSimple );
    // object 사용
}

default copy constructor 를 사용하고 있다. 이해를 돕기위해 copy constructor (혹은 normal constructor) 가 일정양의 메모리를 할당한다고 가정한다. 100bytes 라고 치자. destructor 가 그 100bytes 를 해제하기로 되어 있을 것이다. 이제 여기 문제가 없을까? 좋다. 설명해주겠다. 두개의 오브젝트가 생성되고 있다. (하나는 GetSimple 다른 하나는 SetSimple) 그리고 둘다 100bytes 의 메모리를 할당하고 있다. 맞는가? 이건 흡사 하나의 파일 혹은 디렉토리를 다른 곳으로 복사하는 것과 같다. 그러나 위 예를 보는것과 같이 오직 object 로 조작되고 있다. 그렇다면 왜 100bytes 를 두번씩이나 할당해야 할까? 왜 Simple 오브젝트의 생성 과정에서 할당된 100bytes 를 사용할 수는 없는 것일까? 이전 버전의 C++ 에서는 이걸 해결할 간단한 방법이 없었고 별도의 메모리 관리 루틴을 손수 작성해야 했다. MFC/ATL CString 클래스가 하는 deep copy 와 같이 말이다. 이제 C++0x 에서 이게 가능해졌고 아래의 절차로 진행될 것이다.

  1. 첫번째 오브젝트가 생성되고 내부적으로 메모리를 할당한다.
  2. 이제 이 첫번째 오브젝트가 막 파괴되려고 한다.
  3. 파괴되기 전에 이 메모리 청크를 두번째 오브젝트에 대입한다.
  4. 첫번째 오브젝트에서 메모리 청크와의 관계를 끊는다. (null 등을 대입하는 행위로)
  5. 두번째 오브젝트를 사용한다.
  6. 두번째 오브젝트가 파괴될 때 (첫번째 오브젝트에 의해 생성된) 메모리 청크를 해제한다.

이렇게 함으로써 100bytes 가 절약되었다. 만약 큰 메모리를 사용하는 string, vector, list 에 적용한다면 꽤 많은 양의 메모리와 시간(메모리 생성/해제에 들어가는 시간)을 절약할 수 있을 것이다. 따라서 전반적인 어플리케이션의 퍼포먼스가 향상된다. 위 예에는 RVO 와 NRVO 의 이슈가 관련되어 있기는 하지만 그리 의미 있지는 않다. 어쨌든 이걸 하기 위해 새로운 문법을 소개해야 하겠다. 바로 R-value Reference Declarator: && 이다. 이제 위 코드를 하나씩 바꿔보겠다.

void SetSimple( Simple &&rSimple ) // R-Value NON-CONST reference
{
    // 여기서 메모리 대입 연산을 수행
    Simple object;
    object.Memory = rSimple.Memory;
    rSimple.Memory = nullptr;
 
    // 오브젝트 사용
 
    delete [] object.Memory;
}

위 코드는 이전 오브젝트에서 새 오브젝트로 content 를 옮기는것moving을 보여준다. 마치 파일/디렉토리를 이동시키는 것moving과 비슷하다. const 키워드가 없어진 것에 주목하자. 이전 오브젝트로부터 메모리와의 관계를 없애야 하기 때문이다. class 와 GetSimple 함수가 아래와 같이 바뀌었다. class 는 이제 Memory 라는 포인터 변수를 가진다.(그리고 문제를 간단히 하기 위해 public 으로 선언하였다.) default constructor 는 이 값을 null 로 세팅한다.

class Simple
{
public:
    void* Memory;
    Simple() { Memory = nullptr; }
    Simple( int nBytes ) { Memory = new char[ nBytes ]; }
};
 
Simple GetSimple()
{
    Simple sObj( 10 );
    return sObj;
}

이제 이렇게 호출하면 어떻게 될까?

Simple x;
SetSimple( x );

컴파일러는 에러를 뱉을 것이다. Simple 을 Simple&& 로 변환할 수 없기 때문이다. 변수 'x' 는 임시변수가 아니고 따라서 R-value reference 가 될 수 없다. 이 성질을 이용하여 Simple, Simple& 또는 const Simple& 를 받는 다양한 SetSimple 함수를 오버로드하여 제공할 수도 있다. 이제 임시오브젝트만이 R-value reference 가 될 수 있다는 것을 알았다. R-value 를 가지고 move semantics 라고 알려져있는 것을 구현할 수 있다. move semantic 은 동적으로 할당된 메모리 등의 리소스자원을 원래 오브젝트에서 다른 오브젝트로 옮길는 코드를 작성할 수 있게 해준다. move semantic 을 구현하려면 move constructor 와 move assignment operator 를 클래스에서 구현해야 한다.

The move constructor

이제 move constructor 의 도움을 받아 class 자신안에 있는 오브젝트를 옮기는 것을 구현해보자. 먼저, 아는 바와 같이 copy constructor 는 아래의 형태를 띈다.

Simple( const Simple& );

move constructor 는 비슷하지만 '&' 기호가 하나 더 있다.

Simple( Simple&& );

근데 보는바와 같이 move constructor 는 const 가 아니다. const 를 붙일 수도 있지만 그렇게 하면 move constructor 본연의 기능을 없애는 것이다. 왜일까? move constructor 에서 할 일은 리소스의 소유권을 원본 객체6)로부터 떼어내야 하기 때문이다. 일단 move constructor 가 호출되는 경우를 보자.

Simple GetSimple()
{
    Simple sObj( 10 );
    return sObj;
}

위에서 sObj 는 stack 에 생성되었다. 그리고 return 되는데 이 경우 copy constructor 가 호출된다. 그리고 sObj 의 destructor 가 호출될 것이다. 만약 Simple 클래스 내부적으로 동적 메모리를 할당하여 사용하는 경우 copy constructor 에서 이를 똑같이 다시 할당하여 복사하는 내용을 구현했을 것이다. 하지만 이제 컴파일러는 이런 경우 (R-value 일 경우) move constructor 를 호출하게 하여 필요한 리소스를 재생성할 필요 없이 단지 옮기게만 하게끔 기회를 줄 것이다.

R-value 란 실제로 저장되지 않고 바로 사라지는 값이며 보통 잠시 사용되는 임시값들을 뜻한다.

그리고 중요한 것은 copy constructor 는 명시적 구현이 없으면 컴파일러에 의해 자동으로 생성되는 반면 move constructor 는 자동생성되지 않고 직접 구현해야 한다는 것이다.

이제 새로 갱신된 Simple 클래스를 다시 보자.

class Simple
{
    // The resource
    void* Memory;
 
public:
    Simple() { Memory = nullptr; }
 
    // move constructor
    Simple( Simple &&sObj )
    {
        Memory = sObj.Memory; // 새 주인을 만났고
 
        sObj.Memory = nullptr; // 원래 주인으로부터 빠이~
    }
 
    Simple( int nBytes )
    {
        Memory = new char[ nBytes ];
    }
 
    ~Simple()
    {
        if ( Memory != nullptr )
            delete [] Memory;
    }
};

이제 GetSimple 함수를 호출하면 어떻게 되는지 살펴보자.

  1. GetSimple 로 진입하고 Simple 객체가 stack 에 생성된다.
  2. Simple 생성자가 호출된다.
  3. 생성자는 이자로 들어온 바이트 수만큼의 메모리를 할당한다.
  4. 이제 sObj 가 리턴 가능한 상태가 된다.
  5. 이제 스마트한 컴파일러라면 오브젝트가 단지 이동했다는 것을 파악할 것이고 move constructor 가 있으면 그걸 호출할 것이다.
  6. move constructor 는 새 메모리를 할당하는 대신 위에 구현한 대로 소유권만 넘기고 끝난다.
  7. 소유권이 넘어가고 빈 껍데기가 된 sObj 는 소멸된다.
  8. sObj 의 소멸자에서는 아무런 메모리도 해제하지 않는다. (이미 소유권이 넘어갔으므로 nullptr 이 된다.)

중요한 맥을 다시 짚어보자.

  • move constructor 는 명시적으로 구현되어 있을 때만 호출된다. 그렇지 않으면 copy constructor 가 호출될 것이다.
  • 함수에서 값에 의해 리턴될 경우 (Simple& 나 Simple* 이 아닌 Simple 로 리턴) move constructor 나 copy constructor 가 호출된다. 이건 매우 중요하다.!!
  • move constructor 는 소멸자에서 하는 것들에 대한 소유권을 넘긴다. 이 경우 Memory 변수가 null 이 되고 소멸자에서는 아무 일도 하지 않게 된다.
  • move constructor 와 destructor 의 디자인을 어느정도 비슷하게 하는것이 필요하다. 애초에 의도가 서로 비슷하므로..

원본 오브젝트로부터 새 오브젝트로 내부 리소스의 소유권이 넘어가는 것을 보았다. 이 방법으로 메모리를 할당/해제하는 시간을 절약한 것이다. 일찍이 말한것과 같이 이 정도의 절약은 별것 아니지만 큰 데이터가 빈번하게 생성/소멸될 경우 꽤나 많이 절약하게 된다.

The move assignment operator

아래 코드를 보자.

Simple obj1( 40 );
Simple obj2, obj3;
 
obj2 = obj1;
obj3 = GetSimple();

obj2 = obj1 는 assignment operator 가 호출된다. 여기엔 소유권 이동에 관한 내용은 없고 obj2 가 obj1 의 내용으로 대체될 것이다. 만약 assignment operator 가 없다면 컴파일러에 의해 default assignment operator 가 제공될 것이고 단지 bit 단위 카피만 이뤄지고 원본 오브젝트의 내용 (obj1) 은 아무것도 변하지 않을 것이다.

그리고 move constructor 와 마찬가지로 move assignment operator 도 컴파일러에 의해 자동생성되지 않고 반드시 명시적으로 구현해줘야 한다.

보통 assignment operator 는 아래의 형태를 띈다.

// 간략화를 위해 void 리턴을 하게 했다.
void operator = ( const Simple& );       // 인자에 어떠한 수정도 하지 않는다.

obj3 = GetSimple() 구문은 어떤가? GetSimple 에 의해 리턴된 값은 임시적이고 곧 소멸된다. 따라서 여기에 move semantic 을 적용할 수 있다. 여기 move assignment operator 가 있다.

void operator = ( Simple && );  // 원본 인자를 수정하여 소유권을 떼어낸다. 인자가 임시값이기 때문이다.

글고 여기 수정된 버전의 Simple 클래스를 보자. (간결성을 위해 이전 코드는 생략되었다.) self assignment 는 고려하지 않았다.

class Simple
{
    ... // 생략
 
    void operator = ( const Simple &sOther )
    {
        // 현재 메모리를 해제하고
        delete [] Memory;
 
        // 필요한 메모리를 할당하고 인자로 들어온 내용을 복사한다.
        // 간결함을 위해 new 와 memcpy 는 생략하였다.
    }
 
    void operator = ( Simple &&sOther )
    {
        // 현재 메모리를 해제하고
        delete [] Memory;
 
        // 인자로 들어온 클래스의 내용을 취한다.
        Memory = sOther.Memory;
 
        // 임시 객체의 내용을 취했기 때문에 소유권을 떼어낸다.
        sOther.Memory = nullptr;
    }
};

따라서 obj3 = GetSimple() 의 경우 아래의 과정이 일어난다.

  1. GetSimple 함수가 호출되고 임시객체를 리턴한다.
  2. move assignment operator 가 호출된다. 컴파일러는 어떤 함수에 인자로 사용되는 이 변수가 임시변수라는 것을 알기 때문에 R-value reference 를 사용하는 move assignment operator 를 호출한다.
  3. move assignment operator 가 소유권을 가지고 원본 (임시객체) 으로부터 소유권을 떼어낸다.
  4. 임시객체의 소멸자가 호출되지만 해제할 리소스가 없으므로 아무일도 일어나지 않는다.

move constructor 와 같이, move assignment operator 또한 destructor 와 리소스 할당/해제에 관하여 같은 형태를 가져야 한다.

이어서 또다른 예

지금까지 다뤄왔던 Simple 클래스가 데이터 컨테이너라고 가정한다. string, data/time, array, 또는 그밖에 어떠한 것이든.. 데이터 컨테이너는 아래의 operation 들이 적절하게 overload 되어 있고 또 수행되어야 한다.

Simple obj1( 10 ), obj2( 20 ), obj3, obj4;
 
obj3 = obj1 + obj2;
obj2 = GetSimple() + obj1;
obj4 = obj2 + obj1 + obj3;

그냥 간단히 ”+ operator 가 클래스에 overload 되어 있네요.” 라고 말할수 있다. 물론 맞다. 아래와 같이 operator + 가 Simple 클래스 안에 정의되어 있을 것이다.

// Simple 클래스의 멤버 함수
Simple operator + ( const Simple & )
{
    Simple sObj;
 
    // 적절히 + 연산을 수행하고...
 
    return sObj;
}

그리고 보는 바와 같이 임시 객체가 생성되고 operator + 의 리턴값으로 반환된다. 그리고 결국에는 move assignment operator 가 호출된다. (obj3 = obj1 + obj2 표현식에 대하여) 리소스는 보존될 것이고 좋은 모습이다!

다음 구문인 obj2 = GetSimple() + obj1 에서 왼쪽은 임시객체(R-value)이다. 하지만 오른쪽은 L-value 이고 따라서 임시객체 operator + 함수가 호출될 것이다. 그리고 이전과 마찬가지로 move assignment operator 에 의해 좌항에 assign 된다.

마지막으로 obj4 = obj2 + obj1 + obj3 은 어떤가? 먼저 obj2 + obj1 의 결과로 임시객체가 생성되고 이걸 t1 으로 부를 때 다시 t1 + obj3 에 의해 또다른 임시객체가 생성된다. 마지막으로 이 임시객체가 obj4 에 move assignment operator 에 의해 대입된다.

2013/01/22 12:43
1) 이런 경우는 없을거라 믿고 싶다.
2) begin 멤버 변수가 iterator 형을 반환하더라도 const_iterator 로 대입될 수 있음은 모두 알 것이다.
3) const iterator 는 int* const a; 와 같은 것이고, const_iterator 는 const int* a; 와 같다. 따라서 const_iterator 는 증감연산자 ++ 나 – 이 가능하지만 const iterator 는 이게 불가능하다.
4) error C2466: cannot allocate an array of constant size 0
5) int 와 double 일 경우 double 타입으로 결정되는 것을 뜻함.
6) 이 경우 원본 객체는 move constructor 의 인자에 해당