D.S

blog.angdev.io

Indecisive Programmer: 아카이브

Indecisive Programmer: 아카이브 Indecisive Programmer 월 포스트 유형별 필터링 전체 포스트 텍스트 사진 인용구 링크 채팅 오디오 동영상 질문하기 4월 2014 이건 어떻게 하면 좋을까 (Owned pointer & Incomplete type) 예전부터 나를 괴롭혀오던 문제가 하나 있다. 이건 나의 쓸데없는 고집 때문에 겪는 괴로움이라고 생각하는데 declaration of incomplete type에 대한 이야기이다. 내가 코딩 이야기 말고 무슨 별 다른 이야기를 하겠는가. 하지만 다른 이야기를 기대한 사람이 만약 있다면 기대해도 좋을 것 같다. 앞으로는 블로그에서 코딩 이야기만 하지는 않을 것이라 결심했기 때문이다. 그래봐야 이 블로그를 보는 사람이 얼마나 되겠느냐마는. 어쨌든 다시 돌아가서 코딩 이야기를 해보자. C++에서 어떤 클래스 A가 T라는 타입의 변수를 멤버로 가진다고 가정을 해보자. 이런 상황에서 내가 취했던 행동은 아래와 같이 변해왔다. A의 선언이 담긴 헤더 (이하 A.h) 에 T의 정의가 담긴 헤더를 include 하고, A의 멤버 변수로 T 타입 변수를 선언한다. A.h에 T 타입 선방 선언 (forward declaration) 을 하고, A의 멤버 변수로는 T의 포인터 타입 변수를 선언한다. A.h에 T 타입의 소유권 (ownership) 을 잘 생각해보고, A의 멤버 변수로 그에 맞는 스마트 포인터 타입으로 선언한다. 1에서 2로 바뀐 이유는 대부분 알 것이라 생각한다. 1 전방 선언된 타입은 incomplete type 이므로 포인터로만 선언을 할 수 있기 때문에 자연스럽게 멤버 변수로 선언이 될 때는 포인터 타입을 가지게 되는 것이다. 2에서 3으로 가는 것은 아직 제대로 이루어지지 않고 있다. 지금 내 코드에는 상당히 혼란스럽게도 raw pointer와 smart pointer가 섞여있는데 왜 그런지에 대한 이야기를 하려는 것이 이 포스트이다. 최근에 동아리 홈페이지에 Pointers have little to no use 라는 제목의 링크를 던졌는데 그 때 같이 썼던 감상이 이랬다. 포인터를 쓰지 않는다는 것은 사실 사치이긴 한데 안전하게 쓸 수 있게 되어가고 있다는 소식입니다. owned pointer, shared pointer 부터 시작해서 Option type, reference type wrapper 등을 이용해서 이를 보조한다는건데 어째 rust를 보고 C++ 표준 라이브러리를 보고 있으니 뭔가 드는 생각이 많네요. rust가 safe 덩어리에 unsafe 를 가끔 넣는 느낌이라면 C++은 unsafe 덩어리에 safe 블록을 삽입해서 쓰는 느낌이라고나 할까. raw pointer를 smart pointer로 바꾸려는 생각은 위 감상에서 어느 정도 설명이 되었을 것이라 생각된다. 근데 저 감상을 쓴 글에 달린 댓글이 이러했다. C++에서 멤버 변수로 std::unique_ptr<T>를 쓰다가 T를 컴파일 타임에 모르면 못 쓰는 것 때문에 귀찮았던 적이 있지…. private 영역에서만 쓸 건데도 반드시 알아야 돼 으으 나도 저걸 겪고 std::unique_ptr<T: incomplete type> (이하 std::unique_ptr<T>)은 클래스 멤버 변수로 선언될 수 없는줄 알았다. std::shared_ptr<T>의 경우는 저 문제를 겪지 않기 때문이다. 이는 클래스 생성자와 소멸자 선언을 어떻게 했느냐에 따라서 에러를 볼 수도 있고, 피해갈 수도 있다. 이것이 왜 그런가에 대한 설명은 조금만 찾아보면 나오는데, Incomplete types and shared_ptr / unique_ptr 를 읽어보면 std::unique_ptr<T>의 소멸자가 불릴 상황일 때에 T의 완전한 정의가 필요하다는 점을 알 수 있다. 실제로 컴파일 에러를 보는 것도 이것 때문이다. 그럼 std::unique_ptr<T>가 클래스 멤버 변수로 선언되었다고 했을 때 이것의 소멸자가 호출될 시점은 속한 클래스의 소멸자가 호출될 때라는 것은 자명하다. 이 즈음에서 아래의 코드를 보자. // A.h class T; class A { std::unique_ptr<T> _t; }; 이 코드는 컴파일 에러를 유발할까? 아쉽지만 유발한다. 해답은 default destructor에 있다. 소멸자 선언이 없으면 컴파일러가 자동으로 생성을 하는데 이 때 T의 완전한 정의가 필요하기 때문에 에러를 뿜는 것이다. 그래서 A의 기본 생성자와 소멸자를 선언하고 (custom constructor, destructor) A의 정의가 담긴 A.cpp 따위의 파일에서 T의 정의가 담긴 헤더 파일을 include 해주면 된다. 이것으로 raw pointer를 smart pointer로 바꿔주는 작업에는 전혀 문제가 없다고 생각을 하다가 갑자기 Rule of Zero 가 불현듯 떠올랐다. 혹시 Rule of Three 내지 Rule of Five를 아는가? 나는 학부 2학년 때 객체지향 프로그래밍 이라는 과목에서 Rule of Three 2 를 배운 적이 있다. (정확히는 Big Three 라는 이름으로 배운 것 같다.) 하지만 Rule of Zero는 생소한 이야기일 것이다. 요약하자면 아래와 같다. Classes that have custom destructors, copy/move constructors or copy/move assignment operators should deal exclusively with ownership. Other classes should not have custom destructors, copy/move constructors or copy/move assignment operators. 이를 Rule of All or None 이라 부르기도 하는 것을 봤다. Rule of Zero (None)의 예시 코드는 아래와 같다. class module { public: explicit module(std::wstring const& name) : handle { ::LoadLibrary(name.c_str()), &::FreeLibrary } {} // other module related functions go here private: using module_handle = std::unique_ptr<void, decltype(&::FreeLibrary)>; module_handle handle; }; 나는 이 코드의 간결성에 감탄했다. 그리고 Value semantics 에서 Ownership 문제까지 해결하고 있는 코드이기 때문이다. 실제로 위의 클래스의 인스턴스에 이동 연산을 하면 멤버까지 우아하게 이동된다. 3 컴파일러는 copy/move constructor가 없는 경우에 이를 생성해준다. 위의 경우에는 unique_ptr의 copy constructor = delete이므로 module의 copy constructor 역시 implicitly delete로 선언된다. unique_ptr을 쓰는 것만으로 복사 연산이 불허된다. move constructor의 경우는 문제없이 생성되기 때문에 이동 연산에는 문제가 없다. 하지만 T: incomplete type이 출동하면 어떻게 될까? 그러면 destructor를 만들어줘야 하고, 이것 때문에 default move constructor / move assign operator는 생성되지 않으므로 명시적으로 선언을 해주어야 한다. 4 Rule of Zero 라는 이름이 무색해지는 재앙인 것이다. 완전한 정의를 그냥 알려주면 Rule of Zero를 쉽게 따를 수 있는데 내 고집 때문에 답이 없다. 사실 디자인 패턴이 그렇듯 그대로 따를 수 있는 법칙 같은 것은 없다라는 것을 새삼스레 느낄 수 있다. 이를 해결할 수 있는 owned pointer 해법이 있을지는 아직 고민해보지 않았는데 std를 벗어나기 싫다는 거지같은 생각이 나를 괴롭히겠지. 코딩 별로 안 하는 사람들이 이런 쓸데없는 생각을 많이 할 수도 있다고 하는데 사실이라 할 말은 없다. 코딩을 실제로 하게 되면 constructor, destructor 하나씩 만들어두고, 이동 연산이 필요하면 그 때서야 작성하겠지. 최대한 bolierplate 코드 작성은 하고 싶지 않아서 이런 고민을 하고 앉아있는건데 글을 쓰다보니까 정말 이런 고민을 왜 하고 있나 싶다. 쓰고 보니 문제 제시만 하고 방안에 대한 언급도 없는 뻘글인데. 시험 기간 버프인가? 컴파일 시간을 줄이고 참조 순환을 막기 위해서 전방 선언을 사용한다.  ↩ 그 내용인 즉 copy constructor, copy assign operator, destructor는 붙어다닌다는 것이다. Rule of Five는 C++11에 새롭게 들어간 move semantics 로 인하여 move constructor, move assign operator가 추가되어서 2개가 늘어나 저렇게 부른다.  ↩ 자동 생성된 move constructor, move assign operator 에 의해서  ↩ default move constructor는 = default로 해도 되는 반면 move assign operator는 T의 완전한 정의가 필요하기 때문에 앞서 destructor를 정의하듯 똑같이 작성해주면 된다.  ↩ Apr 21, 2014 다음 페이지 → 2014 1월 2월 3월 4월 5월 6월 7월 8월 9월 10월 11월 12월