-
[29] - (C++) For each, Move Semantics, Lambdas, Uniform Initialization - 1Graphics 2021. 8. 3. 16:32
For each
단순히 Loop를 이르는 말이기도 하지만 Function Template의 기능도 존재합니다. std::for_each의 형태로써 어떤 범위의 요소에 함수를 적용시킬 수 있습니다.
for each loop 이것 말고 <Algorithm>에 정의되어 있는 std::for_each를 이르는 말일 수도 있습니다. 이곳에 구체적으로 설명되어 있습니다. 더 구체적인 정보는 여기에도 있습니다.
모든 값이 두 배가 되어 출력 될 것입니다. Parameter에는 함수만이 들어갈 수 있는 것이 아닙니다. 다음과 같이 Class의 Instance를 집어 넣을 수도 있습니다.
그런데 Function Object라는 개념을 알아야 합니다. 어떤 Range의 Dereferencing 한 iterator의 결과에 Function Object를 적용한다는 것이 첫 번째 정의이기 때문입니다. (Dereferencing은 어떤 메모리의 실제 값을 보여주는 역할을 합니다. 주로 Asterisk(*)로 나타냅니다.) Function Object는 Functor라고도 합니다. 자료구조를 공부하면 마주칠 수 있는 개념입니다.
MSDN의 정보를 참조하면 Functor란 operator ()를 실장[実装/Implements]한 모든 데이터 타입을 이른다고 합니다. 이 괄호 오퍼레이터를 종종 Call operator, Application operator라고도 합니다. 이런 Functor에는 크게 두 가지 장점이 존재합니다. 하나는 상태를 포함할 수 있다는 것이며 다른 하나는 Functor자체가 데이터 타입이기 때문에 template의 parameter로 사용될 수 있다는 점입니다. 위의 Foo를 약간 변형하여 call operator가 parameter의 두 배의 정수를 반환하도록 만들어 어떤 식으로 사용되는지 익혀봅니다.
전 무엇이 장점인지 못느끼겠습니다. 일반 함수의 호출과 엄연히 다른 점은 foo(10)은 함수를 호출하긴 하는데 operator()을 호출하고 있다는 점입니다. 어쩌면 두 번째 장점이 커다란 장점일지도 모릅니다. C++의 Standard Library에는 <Functional>이라는 header file에서 몇 가지 Functor를 제공하고 있습니다. 이 Functor의 사용법 중 하나는 Container를 정렬할 때 기준으로 삼을 수 있다는 점입니다. 즉, 어떤 container의 내용물을 정렬할 때 사용자 정의가 필요하다면 Functor를 통해서 정의를 할 수 있다는 것입니다.
또 다른 장점은 <Algorhitm>에서 발견할 수 있습니다.
이런 식으로 끝도 없는 응용이 가능합니다. Functor가 무엇인지 알고 넘어가면 될 것 같습니다. 아주 유용합니다.
Move Semantics
이에 대해서는 stackoverflow에 대표적인 질문이 하나 있었습니다. 고전에 가까운 이야기이지만 원래 지혜는 세대를 초월하는 힘이 있습니다. 쭉 읽어보니 We need to understand the meaning of Shallow Copy and Deep Copy first. 그러기 위해서는 복사 생성자에 대해서도 알아야 합니다. 기본 생성자에 대해 정확하게 알아야 합니다. 또 알고 또 알고 또 알고 또 알고 또 알고 또 알고 또 알고 또 알고 또 알고 또 알고 또 알고 또 알고 또 알고 또 알고 또 알고 또 알고 또 알고 또 알고 또 알고 또 알고 그냥 끝이 없는게 차라리 죽어서 예수님한테 물어보는 편이 빠르지 않을까요? 제 기억에 복사 생성자가 Shallow/Deep Copy보다 더 어려웠던 개념이었습니다. 배보다 배꼽이 더 크네요.
일단 생성자[Constructor(Ctor)] 자체는 어떤 Class의 Object(혹은 Class의 Instance라고도 합니다.)를 생성하는 member function입니다. Ctor는 object를 생성하면 자동으로 호출되는 함수입니다. 특별한 친구이지만 반환이 없다는 것도 특징 중 하나입니다. Ctor는 크게 세 가지로 나눌 수 있습니다.
1. Default Constructor
2. Parameterized Constructor
3. Copy Constructor
명시를 해도 parameter가 없으면 Defualt constructor입니다. parameter가 있으면 2번, 그리고 지금 알아볼 것이 3번 복사 생성자 입니다. 각각은 쓰임새가 다릅니다. 2번의 경우는 크게 두 가지의 목적을 갖고 있는데 하나는 단순히 서로 다른 Objects를 만들 때, 다른 데이터를 사용하기 위함이고 다른 하나의 목적은 Constructor를 overload하기 위함입니다. 그게 Constructor Overloading입니다.
Copy Ctor는 같은 Class의 Object를 사용해서 다른 Object를 생성[Initialise]하기 위해 사용되는 Member Function입니다. 이제 좀 제대로 알아볼 수 있겠습니다. 보통 ClassName(const ClassName& _oldObj);와 같은 Function Prototype을 갖고 있습니다.
이 코드를 실행시켜보면 개나 고양이나 고양이라고 나옵니다. Dog Instance를 Cat instance를 통해 initialise했습니다. 따라서 정의에 의하면 같은 Class의 object(Cat)로 다른 object(Dog)를 initialise했기 때문에 Copy Ctor가 호출되었고 해당 member function의 정의대로 Dog의 Name이 Cat이 되었습니다. 이게 중요한 것이 아니고 다음과 같은 사실들이 정말 중요합니다. 대체 어떤 경우에 Copy Constructor가 호출이 되는 것인가?
1. 어떤 Class의 Object가 Return 값으로 나왔을 경우.
2. Argument로써 Pass by value를 통해 어떤 Class의 object가 넘겨졌을 경우.
3. 어떤 Class가, 완전히 같은 Class를 통해서 생성된 Object를 바탕으로 생성되었을 경우.
4. Compiler가 임시 Object를 생성할 경우.
그런데 늘 위와 같은 경우에 Copy Ctor를 호출하는 것은 아닙니다. C++ Compiler의 최적화와 관련된 옵션들이 있긴 합니다. 그나저나 이 Copy Ctor가 Shallow/Deep Copy와 무슨 관련이 있을까요? 일단 Default Ctor는 Shallow Copy를 채택하여 수행합니다. Deep Copy를 수행하려면 반드시 user-defined Copy ctor가 반드시 필요합니다. 그리고 여기까지 온 김에 Assignment operator와 Copy Constructor의 차이까지 보면 좋겠습니다. 사실 별 거 없습니다. Assignment operator는 Copy Ctor와 다르게 이미 생성된 object A, B가 있을 때 A = B식으로 사용할 때 호출됩니다. 반면 Copy Ctor는 새롭게 생성하는 Object를 기존의 Object로 생성할 때 호출이 된다는 점이 다릅니다.
마침내, Shallow/Deep Copy를 알아볼 준비가 됐습니다. 복사라는 것은 어떤 Literal Value, Resources, Data Type 등등을 완전히 같은 복제품을 만든다는 의미입니다. 어떤 Object의 완전한 replica를 만들기 위해서는 Object가 갖고 있는 Dynamic Memory등에 따라서 Shallow/Deep Copy를 수행해야 합니다. 일반적으로 어떤 Object가 Dynamic Allocated되어 있다면 Deep Copy를 수행해야 합니다. 그 전에 Shallow Copy가 무엇인지 보고 갑니다.
Shallow Copy
이 복사는 복사를 하려는 object의 그 어떠한 요소도 heap memory section에 있지 않다면 아무런 문제가 없습니다. 다르게 말하면 heap memory section에 어떤 요소가 있는데 Shallow Copy를 수행하면 문제가 생긴다는 뜻입니다. 그 문제에는 대표적으로 Dangling Pointer의 발생 위험입니다. Shallow copy는 복사를 할 때 원본의 데이터를 고스란히 옮겨옵니다. 이때, Heap Memory Section의 데이터는 해당 메모리 주소를 똑같이 가리키게 만듭니다. 이 포인트에서 Dangling Pointer가 발생하는 것입니다. 같은 메모리 공간을 가리키는 포인터이기 때문에 어느 한 곳에서 해당 메모리에 Write을 할 경우 원치 않는 포인터도 값이 변경되는 결과를 초래합니다. 동네 사람들 이것 좀 보세요.
배우기를 heap allocation이 아니면 Shallow copy가 괜찮다고 했습니다. 그래서 귀여운 동물의 이름은 Stack영역에 할당하고 나이는 heap에 주었습니다. 메모리의 영역에 대한 것은 컴퓨터 구조나 운영체제에서 자세히 배웠습니다. dog는 Cat을 통해 만들었기 때문에 Cat의 member variables을 그래도 받습니다. Copy Ctor을 통해서 근거를 확인할 수 있습니다. 결과는 다음과 같습니다. 위에 설명된 대로, 잘 돌아가는 것 같습니다.
Deep Copy
마침내! Deep copy 에서는 다른 Object를 통해 어떤 Object를 생성할 때, 모든 데이터를 복사하고 복사의 원본과 같은 값을 비슷한 메모리에 할당을 해주면서 만들게 됩니다. 이를 위해서는 명시적으로 Copy Ctor을 정의해주어야 합니다. 위의 코드는 잘 보면 명시적인 선언 및 정의는 없었습니다. 그래서 복사 생성자를 만들어 다음과 같이 다시 코드를 실행하면 전혀 다른 결과를 얻을 수 있습니다. 이전 결과와 비교하면 새로운 Dog Object는 Cat과 전혀 다른 메모리 공간을 가리키고 있다는 것을 알 수 있습니다.
Move semantics로 다시 돌아가봅니다. 근데 잠시 Rule of Three에 대한 내용만 빠르게 복기해봅니다. 그냥 메모리 관리를 안전하게 하는 약속 같은 것입니다. Rule of five도 있습니다. Rule of Three에는 Copy Ctor, Assignment Ctor, Dtor가 있고 이 셋을 무조건 명시적으로 정의해주는 것이 경험적으로 안전하다는 것입니다. 그리고 이 move Semantics를 이해하기 전에 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 또 알고 넘어가야 할 내용이 있습니다. lvalue와 rvalue, Copy and swap idiom입니다.
lvalue and rvalue
이 개념은 Move semantics를 위한 관문과도 같은 개념입니다. 간략하게 lvalue는 특정한 Memory의 위치를 가리킵니다. 반면에 rvalue는 아무곳도 가리키고 있지 않습니다. 일반적으로 rvalue는 일시적이고 life cycle이 짧습니다. 생명이 짧다는 것은 특정한 Expression, 특히 세미콜론(;)이 끝나는 즉시 사라진다는 의미이기도 합니다.
가령 int x = 123;과 같은 코드가 한 줄 있을 때 123은 어떤 number(엄밀하게는 Literal Constant입니다.)는 특정한 메모리 공간도 없이 프로그램이 실행 되는 동안 잠깐 레지스터에 존재하는 값에 불과합니다. 반면에 정수 x는 memory stack에 일시적으로나마 공간을 차지하는 값입니다. 그래서 C++는 Assignment operand(=)의 좌측 값으로 반드시 lvalue를 요구하게 됩니다. 메모리에 어떤 공간을 갖는다는 사실은 int* pInt = &x; 와 같은 Expression을 가능하게 해줍니다. 한 가지 눈여겨 보아야하는 점은 Address of operator &는 rvalue를 반환한다는 점입니다.
이 밖에도 상식적으로 rvalue의 메모리를 참조하는 모든 행동이 GCC/G++ Compiler 기준으로 에러를 발생시킵니다. 그런데 한 가지 재미있는 점이 있습니다. 원래 & operator가 rvalue를 반환하기 때문에 Operand의 좌측에 쓸 수 없었지만 const일 경우엔 다음과 같이 쓸 수가 있습니다.
이 논리가 통하는 데는 상당히 간결한 아이디어가 바탕에 있습니다. rvalue의 수정은 기술적으로 불가능 한 것이 아니고 코드의 안정성 때문에 불가능하게 만든 것입니다. 왜냐하면 프로그램은 물론이고 우주 전체에서 수소 원자 1개 + 수소 원자 1개 = 수소원자 2개가 되어야만 하는 것처럼 프로그램에도 일관성을 유지할 수 있어야 하기 때문입니다. 즉, 해당 rvalue의 reference를 constant로 만든다면 프로그램 전체를 통틀어서 Rvalue는 바꿀 수 없다는 성질은 동일하기 때문에 해당 Syntax는 합법적으로 작동하게 된 것입니다.
Version of current g++ compiler 또한 이런 rvalue와 lvalue의 특징은 함수에도 똑같이 적용됩니다. 반환형이 rvalue이면 참조를 할 수가 없습니다. 그러나 나금과 같이 lvalue를 반환하는 함수라면 다음과 같은 코드가 정상으로 작동을 하게 될 것입니다.
그리고 lvalue는 종종 rvalue로 변환이 되기도 합니다. 예를 들어서 int x = 10; int y = 20;이면 (x + y)는 rvalue를 반환합니다. 그런 식으로 동작을 하도록 operator+가 overloading 되어 있습니다.
개념은 이해했습니다. 왜 존재하는지도 알았습니다. 이제 Copy and swap idiom이 무엇인지 확인해보도록 합니다. 그리고 나면 마침내 Move semantics에 대해 이해할 준비가 됩니다.
Copy and Swap Idiom
알고 보니까 정말 별 것 아니었습니다. 이전에 어떤 데이터 타입이든 Resources를 직접 관리한다면 반드시 The Rule of Three을 지켜야 한다고 했습니다.(권장에 가깝긴 합니다.) 셋은 바로 Dtor, Copy Ctor, Assignment Ctor였는데 그 중에 Assignment Ctor를 implement하는 지침서입니다. 왜냐하면 Assignment Ctor 자체가 미묘하고 까다롭기 때문에 이를 만들기 위한 방향성을 잡아준 것입니다. 크게 두 가지 내용을 포함하고 있는데 하나는 Code Duplication을 피하라는 내용이고 다른 하나는 Strong Exception Guarantee를 제공해야 한다는 내용입니다.
String Exception Guarantee라는 것은 원래 Exception Safety라는 타이틀로 총 네 가지의 원칙 중 하나입니다. 간결하게 정리하면 "어떤 operation이 실패하더라도 부작용을 낳아서는 안되며 관여된 데이터에 어떠한 손실도 있어서는 안된다."라는 것입니다. 그리고 이것이 바로 Copy and Swap idiom의 핵심적인 내용입니다.
어떤 임의의 두 개의 Buffers X와 Y가 있다고 했을 때, X에 어떤 operation을 가하고 결과가 Valid하다면 Y에 복사하는 함수 F가 있다고 가정합시다. 두 가지 경우로 나뉘는데 if (F() == Fail) else (F() == Succeed) 일 것입니다. 만약에 실패하더라도 buffer X의 데이터는 손실되어서는 안됩니다. 그리고 이 포인트가 현재 공부하려는 Move Semantics을 이해하는데 핵심적인 내용 중 하나입니다. 또 다른 핵심은 C++ Programme이 상당히 빠르게 동작함에도 불구하고 어떤 Object를 반환하거나 할당할 때, 필요 없는 복사를 실행하는 경우가 종종 있는데 이를 해결하기 위해 고안된 개념이기도 합니다. 다음과 같은 코드가 있습니다.
언뜻 문제가 없는 코드 같습니다. 실행도 되긴 합니다. 하지만 공간의 효율의 관점에서 보면 문제가 있습니다. v = DoubleValues(v);를 실행하는 순간에 우리는 이론적으로 v에 대한 두 개의 복사본을 갖고 있습니다. 하나는 함수를 호출하며 들어가서 만든 새로운 newValues, 그리고 parameter로 전달해준 v의 복사본입니다. 그런데 여기서 return을 해버리면 newValues의 복사본이 또 생겨버립니다. 처음 만든 lvalue인 newValues vector는 scope를 빠져나가며 사라지되 반환형인 rvalue가 만들어지는 겁니다. Vector의 사이즈가 100이 아니라 압도적으로 거대했다면 성능에 엄청난 지장을 주었을 것입니다.
마침내, Move Semantics에 대한 직접적인 내용으로 돌아왔습니다. 위의 문제를 해결하게 위해서 vector의 포인터를 사용하는 방법도 있긴 하지만 C++은 memory allocation을 최대한 피하는 쪽으로 디자인 하는 것이 권장됩니다. return을 하며 복사된 rvalue인 vector newValues를 만들지 말고 그냥 있던 오브젝트를 이동시켜주면 어떨까? 이것이 Move Semantics의 출발입니다. C++ 03 표준에서는 어떤 값이 임시적인지 아닌지 알 방법이 없었습니다. 그래서 복사 생성자와 할당 생성자를 둘 다 만들어야 했고 rvalue의 값을 추출할 어떠한 방법도 없었습니다. 하지만 C++11표준에 들어오며 방법이 생겼습니다.
그리고 그 방법이란 rvalue reference, Move Semantics입니다. 곧 사라질 값을 복사해서 쓰는 것이 아니라 값 자체를 잡아서 사용하는 방법이라고 할 수 있습니다! move semantics는 C++11의 기능인 rvalue reference에 전적으로 의지하고 있습니다. 이 전부터 계속 봤던 내용을 토대로 생각해보면 이해가 됩니다. rvalue reference는 오직 temporary value에만 반응하는 reference를 일컫습니다. 이 rvalue reference는 '&&'와 같은 Operator를 사용하며 const 일수도 non-const 일수도 있습니다. 반면에 일반적인 lvalue reference는 반드시 const만 가능했다는 사실을 위에서 배웠습니다.
rvalue reference는 Const/Non-const 가리지 않습니다 const/non-const를 가리지 않는다는 것은 rvalue를 받은 vector임에도 불구하고 다음과 같이 사용할 수가 있다는 뜻이기도 합니다. Mutable rvalue reference라는 이름이 있습니다. 다음과 같은 코드를 보며 이해하면 좋습니다.
PrintName이라는 함수는 첫 번째에 const lvalue를 받는 함수가 있고 두 번째에 해당 함수를 overload해서 mutable rvalue reference를 받도록 했습니다. GetName함수의 반환은 rvalue입니다. 만약 overload한 printName함수가 없었다면 const lvalue가 모든 값을 받아서 처리했을 것입니다. 그래서 이런 내용이 생성자, Class와 어떤 연관이 있다는 것일까요?
rvalue와 lvalue를 구분할 수 있다는 사실은 대단히 중요합니다. 이제 Copy Constructor나 Assignment Constructor를 호출할 때, Argument로 나타난 어떤 instance가 rvalue라면 새롭게 메모리를 할당하지 않고 해당 instance를 이동(MOVE)시키게 될 것입니다. 확실한 차이가 있습니다. 위의 Animal Class를 사용해서 shallow/deep copy를 설명했을 때 사용한 코드를 보면 Copy ctor는 Argument가 rvalue이든 lvalue이든 새롭게 메모리를 할당하기만 합니다. 애초에 디자인을 구분을 할 수 없게 만들었습니다. Class의 복사되는 rvalue가 일반적인 primitive(integer, float etc)라면 복사를 해주면 됩니다. 하지만 pointer의 경우엔 약간 특별한 처리를 해야 합니다.
기존 복사 생성자와 차별점은 새로운 메모리 할당이 없으며 복사의 원본의 포인터를 null값으로 보내버린다는 사실입니다. 원본 포인터를 nullptr로 보내지 않으면 임시로 만들어진 rvalue가 사라질 때 호출한 Dtor로 인해서 복사된 값이 아무것도 없는 메모리를 가리키는 참사가 발생합니다.
Ref:
1. https://www.internalpointers.com/post/understanding-meaning-lvalues-and-rvalues-c/
2. https://www.geeksforgeeks.org/shallow-copy-and-deep-copy-in-c/.
3. https://stackoverflow.com/questions/3106110/what-is-move-semantics.
4. https://eli.thegreenplace.net/2011/12/15/understanding-lvalues-and-rvalues-in-c-and-c/.
5. https://www.tutorialspoint.com/cplusplus/cpp_constants_literals.htm.
6. https://stackoverflow.com/questions/3279543/what-is-the-copy-and-swap-idiom.
7. https://en.wikipedia.org/wiki/Exception_safety.
8. https://en.cppreference.com/w/cpp/language/move_constructor.
9. https://www.cprogramming.com/c++11/rvalue-references-and-move-semantics-in-c++11.html
'Graphics' 카테고리의 다른 글
[31] - (C++) For each, Move Semantics, Lambdas, Uniform Initialization - 3 (0) 2021.08.05 [30] - (C++) For each, Move Semantics, Lambdas, Uniform Initialization - 2 (0) 2021.08.03 [28] - Bindable/Drawable System - 2 (0) 2021.08.02 [27] - Bindable/Drawable System (0) 2021.08.01 [26] - Z-buffer (0) 2021.07.31