Search Results for '2019/10/09'


1 POSTS

  1. 2019/10/09 C++의 스마트 포인터 by 사무엘

1. 스마트 포인터의 필요성

C/C++에서 포인터로 참조하는 동적 메모리가 안전하게 관리되기 위해서는.. 가장 간단하게는 포인터의 생명 주기와 그 포인터가 가리키는 메모리 실체의 생명 주기가 동일하게 유지돼야 할 것이다. 어느 한쪽이 소멸되면 다른 쪽도 소멸돼야 한다. C++에서는 이 정도 절차는 포인터를 클래스로 감싸고 그 클래스의 생성자와 소멸자 함수를 구현함으로써 자동화할 수 있다.

하지만 이것만으로 문제가 다 해결되는 건 아니다. 어떤 메모리에 대한 포인터의 ownership이 더 깔끔하게 관리되고 통제돼야 한다. 멀쩡한 주소값이 딴 걸로 바뀌어서 원래 가리키던 메모리로 접근 불가능해지거나(leak..), 이미 해제된 메모리를 계속 가리키고 있다가 사고가 나는 일도 없어야 한다.

그런 일을 예방하려면 여러 포인터가 동일 메모리를 참조하는 것을 완전히 금지하고 막든가, 아니면 reference count 같은 걸 따로 둬서 그런 상황에 대비를 해야 한다. 실행시켰을 때 뻑이 날 만한 짓은 아예 컴파일이 되지 않고 거부되게 해야 한다.
이런 메모리 관리를 자동으로 해 주는 클래스가 표준 C++ 라이브러리에도 물론 구현돼 있으며, 크게 두 가지 관점에서 존재한다.

  • 배열 지향: POD 또는 비교적 단순한 오브젝트들의 동적 배열로, 원소들의 순회, 추가· 삭제와 전체 버퍼 재할당 같은 동작에 최적화돼 있다. 원소 전체 개수와 메모리 할당량 정보가 별도로 들어 있으며, 문자열 클래스도 어찌 보면 배열의 특수한 형태라고 간주할 수 있다. [] 연산자가 오버로딩 돼 있다.
  • 오브젝트 지향: 단일 오브젝트 중심으로 메모리 할당 크기보다는 소유자(ownership) 관리에 더 최적화돼 있다. 그래서 구현 방식에 따라서는 원소 개수 대신 레퍼런스 카운트 정보가 있곤 한다. 담고 있는 타입 형태로 곧장 활용 가능하게 하기 위해, ->와 * 같은 연산자가 반드시 오버로딩 돼 있다.

C/C++은 배열과 포인터의 구분이 애매하니 helper class는 각 분야에 특화된 형태로 따로 구현되었다는 것을 알 수 있다.
배열 버전이야 std::vector라는 유명한 클래스가 있고, 오브젝트를 담당하는 물건을 우리는 smart pointer라는 이름으로 오랫동안 불러 왔다.

Windows 진영에서도 ATL 내지 WTL 라이브러리에는 일반 포인터뿐만 아니라 COM 인터페이스를 감싸서 소멸자에서 Release를 해 주고, 대입 연산자 및 복사 생성자에서 AddRef 따위 처리를 해 주는 간단한 클래스가 물론 있었다.
소멸자는 예외 처리가 섞여 있을 때 더욱 빛을 발한다. 함수의 실행이 종료되는 경로가 여럿 존재하게 됐을 때 goto문을 안 쓰고도 메모리 단속이 꼼꼼하게 되는 것을 언어와 컴파일러 차원에서 보장해 주기 때문이다. 그리고 이 정도 물건은 C++ 좀 다루는 프로그래머라면 아무라도 생각해 내고 구현할 수 있다.

2. 초창기에 도입됐던 auto_ptr과 그 한계

C++은 이런 스마트 포인터도 표준화하려 했으며, 그 결과로 auto_ptr이라는 클래스가 C++98 때부터 도입됐다. 선언된 헤더는 #include <memory>이다.
그러나 auto_ptr는 오늘날의 최신 C++의 관점에서 봤을 때는 썩 좋지 못한 설계 형태로 인해 deprecate됐다. 이미 이걸 사용해서 작성돼 버린 레거시 코드를 실행하는 것 외의 용도로는 사용이 더 권장되거나 지원되지 않게 되었다.

그 대신, C++11부터는 용도를 세분화한 unique_ptr, shared_ptr, weak_ptr이라는 대체제가 등장했다. 이거 마치 C-style cast와 C++ *_cast 4종류 형변환의 관계처럼 보이지 않는가? =_=;;

auto_ptr은 한 메모리를 오직 한 포인터만이 참조하도록 하고 포인터가 사라질 때 소멸자도 호출해 주는 최소한의 기본 조치는 잘 해 줬었다. auto_ptr<T> ptr(new T(arg1, ...)) 같은 꼴로 선언해서 사용하면 됐다. 하지만...

(1) 단일 포인터와 배열의 구분이 없었다.
물론 스마트 포인터는 전문적인 배열 컨테이너 클래스와는 용도가 다르니, 원소의 삽입· 삭제나 원소 개수 관리, 메모리 재할당 처리까지 할 필요는 없다.

하지만 클래스의 소멸자에서 호출해 주는 clean-up을 별도의 템플릿 인자로 추상화하지는 않았고 그냥 delete ptr로 고정해 놓았기 때문에.. 당장 delete와 delete[]조차도 구분할 수 없어서 번거로웠다. 다시 말해 auto_ptr<T> ptr(new T[100]) 이런 식으로 써먹을 수는 없다.

(2) 포인터의 ownership을 관리하는 것까지는 좋으나.. 그게 복사 생성자 내지 대입 연산자에서 우항 피연산자를 변조하는 꽤 기괴한 형태로 구현돼 있었다.
무슨 말이냐 하면.. auto_ptr<T> a(ptr), b에서 b=a 또는 b(a)라고 써 주면.. b는 a가 가리키는 값으로 바뀜과 동시에 a가 가리키는 값은 NULL로 바뀌었다. 즉, 포인터와 메모리의 일대일 관계를 유지시키기 위해, 소유권은 언제나 복사되는 게 아니라 이동되게 한 것이다.

그렇게 구현한 심정은 이해가 되지만, 대입 연산에서 A=B라고 하면 A만 변경되어야지, B가 바뀌는 건 좀 납득이 어렵다.
복사 생성자라는 것도 형태가 T::T(const T&)이지, T::T(T&)는 아니다. 차라리 임시 객체만 받는 R-value 이동 전용 생성자라면 T::T(T&&)이어서 우항의 변조가 허용되지만, 복사 생성자는 그런 용도가 아니다.

(3) 위와 같은 특성이랄지 문제로 인해.. auto_ptr은 call-by-value 형태로 함수의 인자나 리턴값으로 그대로 전달했다간 큰일 났다.
메모리의 소유권이 호출된 함수의 인자로 완전히 옮겨져 버리고, 그 함수가 끝날 때 그 메모리는 auto_ptr의 소멸자에 의해 해제돼 버리기 때문이다. 이 문제를 컴파일러 차원에서 잡아낼 수 없다. (뭐, 이미 free된 메모리를 이중으로 해제시키는 사고는 나지 않는다. 깔끔한 null pointer 접근 에러가 날 뿐.)

auto_ptr을 함수 인자로 전달하려면 그냥 call-by-reference로 하든가, 아니면 그 원래의 T* raw 포인터 형태로 전해야 했다.
아니, 함수 인자뿐만 아니라 값을 그대로 함수의 리턴값으로 전할 때, 혹은 vector 및 list 같은 컨테이너에다 집어넣을 때 등.. 임시 객체가 발생할 만한 모든 상황에서 동일한 문제가 발생하게 된다.

이게 제일 치명적이고 심각한 문제이다. 여러 함수를 드나들고 컨테이너에다 집어넣는 것도 raw pointer와 다를 바 없이 가볍게 되라고 smart pointer를 만들었는데 그러지 못한다면.. 이걸 만든 의미가 없다. 그러면 한 함수 안에서 달랑 소멸자 호출만 자동화해 주는 것 말고는 쓸모가 없다.
또한, 매번 call-by-reference로 전하는 건 엄밀히 말해 포인터의 포인터.. 즉, 포인터를 정수가 아니라 구조체 같은 덩치 큰 물건으로 취급하는 거나 마찬가지이고..

이런 이유로 인해 auto_ptr은 좋은 취지로 도입됐음에도 불구하고, 현재는 이런 게 있었다는 것만 알고 최신 C++에서는 잊어버려야 할 물건이 됐다.
(1) C 라이브러리 함수라든가(gets...) (2) C++ 키워드뿐만 아니라(export) (3) C++ 라이브러리 클래스 중에서도 흑역사가 생긴 셈이다.

auto_ptr이 무슨 보안상의 결함이 있다거나 성능 오버헤드가 크다거나 한 건 아니다. 21세기 이전에는 C++에 R-value 참조자 같은 문법이 없었으니 복사 생성자에다가 move 기능을 집어넣을 수밖에 없었다. 나중에 C++에 언어 차원에서 smart pointer의 불편을 해소해 주는 기능이 추가된 뒤에도 이미 만들어진 클래스의 문법이나 동작을 변경할 수는 없으니 새 클래스를 따로 만들게 된 것일 뿐이다.

3. unique_ptr

auto_ptr의 가장 직접적인 대체제는 unique_ptr이다.
얘는 최신 C++에서 새로 추가된 문법을 활용하여 단일 개체와 배열 개체를 구분할 수 있다. unique_ptr<T>와 unique_ptr<T []>로 말이다. 신기하다..;;
그리고 템플릿 가변 인자 문법을 이용하여 new를 생략하고 std::make_unique<T>(arg1, arg2..) 이렇게 객체를 생성할 수도 있다. 얘는 C++14에서야 도입된 더 새로운 물건이다.

unique_ptr은.. 말 많고 탈 많던 복사 생성자와 대입 연산자가 막혀 있다. 함수에 날것 형태로 전달하거나 컨테이너에 집어넣는 등의 시도를 하면.. 그냥 컴파일 에러가 나게 된다. 그래서 안전하다.
이전의 auto_ptr이 하던 것처럼 소유권을 옮기는 것은 R-value 이동 생성자라든가 std::move 같은 다른 방법으로 하면 된다.

어떤 클래스에 대해서 복사 생성자와 대입 연산자가 구현돼 있지 않으면 컴파일러가 디폴트, trivial 구현을 자동 생성하는 편이다. 각 멤버들에 대한 memcpy 신공 내지 대입 연산자 호출처럼 해야 할 일이 비교적 직관적으로 뻔히 유추 가능하기 때문이다. 하지만 클래스에 따라서는 그런 오지랖이나 유도리가 바람직하지 않으며 이를 금지해야 할 때가 있다. 인스턴스가 단 하나만 존재해야 하는 singleton 클래스, 또는 저렇게 반드시 1핸들, 1리소스 원칙을 유지해야 하는 클래스를 구현할 때 말이다.

그걸 금지하는 가장 전형적이고 전통적인 테크닉은 해당 함수를 private으로 선언해 버리는 것이 있다. (정의는 당연히 하지 말고)
하지만 이것도 friend 함수에서는 안 통하는 한계가 있기 때문에 최신 C++에서는 액세스 등급과 별개로 상속 받았거나 디폴트 구현된 멤버 함수의 사용을 그냥 무조건적으로 금지해 버리는.. = delete라는 문법이 추가되었다. 순수 가상 함수를 나타내는 = 0처럼 말이다! unique_ptr은 이 문법을 사용하고 있다.

그럼 unique_ptr은 컨테이너에 집어넣는 게 전혀 불가능한가 하면.. 그렇지 않다.

vector<unique_ptr<T> > lc;
lc.push_back( unique_ptr<T>(new T) );

처럼 push_back이나 insert에다가 T에 속하는 변수를 줄 게 아니라 저렇게 애초부터 R-value 임시 객체를 주면 된다.
그러면 임시 객체의 ownership이 컨테이너 안으로 자연스럽게 옮겨지고, 컨테이너 안의 unique_ptr만이 유일하게 T를 가리키고 있게 된다.

얘는 auto_ptr보다 상황이 훨씬 더 나아졌고 이제 좀 쓸 만한 smart pointer가 된 것 같다.
사실, 작명 센스조차도.. auto는 도대체 뭘 자동으로 처리해 준다는 건지 좀 막연한 구석이 있었다. 그게 unique/shared로 바뀐 것은 마치 '인공지능'이라는 막연한 용어가 AI 암흑기를 거친 후에 분야별로 더 구체적인 기계학습/패턴인식 같은 말로 바뀐 것과 비슷하게 들리기도 한다. ㅎㅎ

4. shared_ptr와 weak_ptr

그럼 다음으로, shared_ptr을 살펴보자.
얘는 마치 COM의 IUnknown 인터페이스처럼 reference counting을 통해 다수의 포인터가 한 메모리를 참조하는 것에 대한 대비가 돼 있다. 그래서 unique_ptr과 달리, 대입이나 복사를 자유롭게 할 수 있다.

(1) 날포인터는 그냥 대책 없이 허용하기 때문에 ownership 문제가 발생하고.. 아까 (2) auto_ptr은 무조건 ownership을 옮겨 버리고, (3) unique_ptr은 깔끔하게 금지하는데 (4) 얘는 참조 횟수를 관리하면서 허용한다는 차이가 있다. 소멸자는 가리키는 놈의 reference count를 1 감소시켜서 그게 0이 됐을 때만 실제로 메모리를 해제한다.

그래서 shared_ptr은 크기 오버헤드가 좀 있다.
unique_ptr은 일반 포인터 하나와 동일한 크기이고 기술적으로 machine word 하나와 다를 바 없는 반면, shared_ptr은 reference count 데이터를 가리키는 포인터를 추가로 갖고 있다. 일반 포인터 두 개 크기를 차지한다.

이는 static_cast보다 dynamic_cast가 오버헤드가 더 큰 것과 비슷한 모습 같다. 그리고 멤버 포인터가 다중 상속 하에서의 this 오프셋 보정 때문에 추가 정보를 갖고 있다면, 얘는 ownership 관리 때문에 추가 정보를 갖고 있다는 점이 비교된다.

끝으로, weak_ptr이라고, shared_ptr와 같이 쓰이는 포인터도 있다. 얘는 이름에서 유추할 수 있듯이 reference count를 건드리지 않는다. 순환 참조 문제를 예방하려면 A에서 B를 참조한 뒤에 B에서 또 A를 참조할 때는 레퍼런스 카운트를 건드리지 않아야 하기 때문이다.
순환 참조는 단순히 A→B→A뿐만 아니라 A→B→C→A 같은 더 복잡한 형태로도 발생하며, 일단 발생한 것을 감지하기란 몹시 난감하다. 그러니 weak_ptr이라는 개념이 반드시 필요하다.

그럼 reference count를 건드리지 않고 shared_ptr에 접근하려면 그냥 raw 날포인터를 얻어서 간단히 써도 될 텐데 굳이 weak_ptr이 따로 존재하는 이유는? 비록 ref count를 건드리지 않더라도 날포인터보다는 더 안전한 놈이 필요하기 때문이다.

weak_ptr은 다른 shared_ptr을 가리킬 수 있다.
shared_ptr은 자신과 동일한 shared_ptr로부터 참조받은 횟수와, weak_ptr로부터 참조받은 횟수를 따로 관리한다. 그렇기 때문에 shared_ptr은 이렇게 2개의 숫자로 이뤄진 레퍼런스 데이터도 따로 동적 생성해서 포인터로 가리키면서 동작한다.

weak_ptr이 가리키는 객체에 실제로 접근하려면 weak_ptr::lock()을 호출해서 weak로부터 shared_ptr을 잠시 생성해야 한다. 이 동안은 shared_ptr의 실제 레퍼런스 카운트가 증가하기 때문에 한쪽의 스마트포인터가 소멸되더라도 dangling pointer 현상이 발생하지 않는다. 이게 weak_ptr이 날포인터와의 차이점이다.

하지만 이 lock이 언제나 성공하지는 않을 수 있다. lock이 걸리지 않았을 때 weak가 shared를 가리키고 있는 건 shared의 실제 참조 횟수에 아무 영향을 끼치지 않는다. 그렇기 때문에 이 동안은 shared가 가리키는 객체는 순환 참조 없이 소멸이 가능하다.

그럼 weak_ptr 따위 안 쓰고, 그냥 shared_ptr를 놔두고 평소엔 null을 저장했다가 lock을 걸 상황에만 원래 포인터 값을 참조하는 것과 무슨 차이가 있느냐는 의문이 생길 수 있는데.. weak_*는 그것보다 더 안전하다. 자신이 가리키는 객체가 소멸되었는지의 여부를 expired() 함수로 간편하게 알 수 있다. 레퍼런스 카운트를 이중으로 관리하고 메모리도 이중으로 관리하는 성능 오버헤드를 괜히 감수한 게 아니다.

weak_ptr은 shared_*와 마찬가지로 크기가 포인터 2개이다. 단, 사용법이 unique_*나 shared_*와는 좀 다르다.
저 둘은 ->나 * 연산자를 이용해서 자신이 가리키는 객체에 바로 접근할 수 있는 반면, weak_*는 그렇지 않다. 그리고 실제 객체를 생성하는 make_unique와 make_shared 함수는 있는 반면, make_weak는 없다. weak_*는 그 정의상 기존 shared_*로부터 유도되어 생성되는 걸 가정하기 때문이다.

이상이다. 그냥 생성자와 소멸자를 적절히 구현해 주고 ->와 *만 오버로딩 해 주면 끝일 것 같은 smart pointer도 깊게 들어가면 내막이 생각보다 더 복잡하다는 것을 알 수 있다.
Rust 언어는 garbage collector 기반이 아니면서 더 독특한 방식으로 메모리 소유권을 관리한다던데 그 내막이 어떠했던지가 다시 궁금해진다.

5. 여담

(1) = delete는 다시 봐도 참신하기 그지없다. delete라는 키워드가 연산자 말고 이런 용도로도 활용되는 날이 오더니!
배열 첨자 연산자이던 []와 구조체 참조 연산자이던 ->가 람다 선언에서 의미가 완전히 확장된 것만큼이나 참신하다.
하긴, 옛날에 템플릿이 처음 등장했을 때.. 그저 비교 연산자일 뿐이었던 <와 >가 완전히 새로운 여닫는 형태로 사용되기 시작한 것도 정말 충격적인 변화였을 것이다.

(2) 글쎄, 멤버 함수의 접근을 금지하는 방법이 저렇게 도입됐는데, 어떤 클래스에 대해서 Java의 final이나 C#의 sealed처럼 상속이 더 되지 않게 하는 옵션은 C++에 도입되지 않으려나 모르겠다. C++은 타 언어에 없는 protected, private 상속이 존재하지만 상속 자체를 금지하는 옵션은 없어서 말이다.

특히 내부 구조가 아주 간단하고 가상 함수가 존재하지 않는 것, 특히 소멸자가 가상 함수 형태로 별도로 선언되지 않은 클래스는 상속을 해도 어차피 polymorphism을 제대로 살릴 수 없다. 그냥 단순 기능 확장에만 의미를 둬야 할 것이다.
Java는 모든 함수가 기본적으로 가상 함수일 정도로 유연한데도 이와 별개로 상속을 금지하는 옵션이 있는데.. 그보다 더 경직된 언어인 C++은 의외로 그런 기능이 없다.

(3) C/C++의 사고방식에 익숙한 프로그래머라면 포인터란 곧 메모리 주소이고, 본질적으로 machine word와 동일한 크기의 부호 없는 정수 하나일 뿐이라는 편견 아닌 편견을 갖고 있다.
하지만 객체지향이라든가 함수형 등 프로그래밍 언어 이론을 조금이라도 제대로 구현하려면 숫자 하나만으로 모든 것을 표현하기엔 부족한 포인터가 얼마든지 등장하게 된다.

앞서 다뤘던 shared_ptr이라든가 다중 상속을 지원하는 멤버 함수 포인터..
그리고 자기를 감싸는 문맥 정보가 담긴 클래스 객체 포인터라든가 람다 함수 포인터 말이다.
C++은 전자를 기본 지원하지 않기 때문에 모든 클래스들이 Java 용어로 치면 개념적으로 static class인 거나 마찬가지이다.
그리고 후자를 기본 지원하지 않기 때문에 람다는 캡처가 없는 놈만 기존 함수 포인터에다 담을 수 있다.

그런 것들이 내부적으로 어떻게 구현되고 구현하는 시공간 비용이 어찌 되는지를 프로그래머라면 한 번쯤 생각할 필요가 있어 보인다.

(4) C++에서 class T; struct V; 처럼 이름만 전방 선언된 incomplete type에 대해서는 제일 단순한 직통 포인터, 그리고 무리수가 좀 들어간 멤버 포인터 정도만 선언할 수 있다. T나 V의 실체를 모르니 이런 타입의 개체를 생성하거나, 포인터를 실제로 참조해서 뭔가를 할 수는 없다.
그런데 이런 불완전한 타입을 가리키는 포인터를 상대로 delete는 가능할까? 난 이런 상황에 대해 지금까지 한 번도 생각해 본 적이 없었다.

sizeof(T)의 값을 모르더라도 포인터가 가리키는 heap 메모리 블록을 free하는 것은 얼마든지 가능하다. 애초에 malloc/void가 취급하는 것도 아무런 타입 정보가 없는 void*이니 말이다.
그러니 operator delete(ptr)은 할 수 있지만, 해당 타입에 대한 소멸자 함수는 호출되지 못한다.

컴파일러는 이런 코드에 대해서 경고를 띄우는 편이다. Visual C++의 경우 C4510이며, delete뿐만 아니라 delete[]에 대해서도 동일한 정책이 적용된다.

Posted by 사무엘

2019/10/09 08:35 2019/10/09 08:35
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1671


블로그 이미지

그런즉 이제 애호박, 단호박, 늙은호박 이 셋은 항상 있으나, 그 중에 제일은 늙은호박이니라.

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

«   2019/10   »
    1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30 31    

Site Stats

Total hits:
2620368
Today:
3367
Yesterday:
1544