7.3 로우 레벨 메모리 연산
특정 애플리케이션이나 레거시 코드에서 로우 레벨 메모리를 다뤄야 할 때가 있다. 메모리를 저수준으로 관리하는 테크닉을 알아두면 여러모로 도움이 된다.
7.3.1 포인터 연산
C++ 컴파일러는 포인터 연산을 수행할 때 포인터에 선언된 타입을 이용한다. 예를 들어 다음과 같이 int 타입의 힙 배열을 선언한 경우를 살펴보자. 포인터 연산의 강점은 myArray + 2와 같이 표현식으로 포인터를 표현하고, 이를 이용해서 더 작은 정수 배열을 표현할 수 있다는 데 있다.
int* myArray = new int[8];
myArray[2] = 10;
*(myArray + 2) = 10; /* 포인터 연산으로 역참조합니다. */
다음과 같이 와이드 문자열(wide string)을 인수로 받아서 그 값을 대문자로 변환한 문자열을 새로 만들어 리턴하는 함수가 있다고 하자. 이 함수에 myString을 전달하면 대문자로 변환할 수 있는데, 이때 myString의 일부분만 대문자로 변환하려면 원하는 부분의 시작점을 포인터 연산으로 표현한다.
wchar_t* toCaps(const wchar_t* inString); /* 와이드 문자열을 인수로 받아 대문자로 변환하여 리턴합니다. */
const wchar_t* myString = L"Hello, world!";
toCaps(myString + 7); /* world만 WORLD로 변환합니다. */
한편, 포인터 연산의 뺄셈도 유용하다. 한 포인터에서 같은 타입의 포인터를 빼면 두 포인터 사이에 몇 바이트가 있는지가 아니라 포인터에 지정한 타입의 원소가 몇 개 있는지 알 수 있다.
7.3.2 커스텀 메모리 관리
메모리 관리와 같은 특수한 작업을 수행할 때는 메모리를 직접 다루어야 할 수 있다. 핵심은 클래스에 큰 덩어리의 메모리를 할당해놓고 필요할 때마다 잘라 쓰는 데 있다. 이렇게 메모리를 직접 관리하면 new로 메모리를 할당하고 현재 프로그램에서 얼마나 할당했는지 기록하는 데 필요한 공간, 즉 오버헤드를 줄일 수 있다. 이렇게 기록해두어야 delete를 호출할 때 필요한 만큼 해제할 수 있다. 크기가 작은 객체가 많거나 사용하는 객체의 수가 엄청 많을 때 이러한 오버헤드는 큰 영향을 미친다.
7.3.3 가비지 컬렉션
가비지 컬렉션(garbage collection, GC)은 메모리를 정상 상태로 유지하기 위한 최후의 보루로, 이 환경에서 더 이상 참조하지 않는 객체는 런타임 라이브러리에 의해 일정한 시점에 자동으로 해제된다.
가비지 컬렉션을 구현하는 기법 중에 표시 후 쓸기(mark and sweep)란 알고리즘이 있다. 이 방식에 따르면 가비지 컬렉터가 프로그램에 있는 모든 포인터를 주기적으로 검사한 후, 여기서 참조하고 있는 메모리를 계속 사용하고 있는지 여부를 표시한다. 한 주기가 끝날 시점에 아무런 표시가 되어있지 않은 메모리는 더 이상 사용하지 않는 것으로 간주하고 해제한다. 가비지 컬렉션의 단점을 몇 가지 소개하면 다음과 같다.
- 가비지 컬렉터가 작동하는 동안 프로그램이 멈출 수 있다.
- 카비지 컬렉터가 있으면 소멸자가 비결정적으로(non-deterministically) 호출된다. 객체는 가비지 컬렉터에서 처리하기 전에는 제거되지 않기 때문에 객체가 스코프를 벗어나더라도 소멸자가 즉시 실행되지 않는다. 즉, 소멸자에서 처리하는 리소스 정리 작업은 일정한 시점에 이르기 전에는 실행되지 않는데, 얼마나 기다려야 할지 미리 알 수 없다.
7.3.4 객체 풀
객체 풀(object pool)은 타입이 같은 여러 개의 객체를 지속적으로 사용해야 하지만, 매번 객체를 생성하면 오버헤드가 상당히 커지는 상황에 적용하기 좋다. 성능 효율을 높이기 위해 객체 풀을 사용하는 방법은 25장에서 자세히 설명한다.
7.4 스마트 포인터
스마트 포인터(smart pointer)를 사용하면 동적으로 할당한 메모리를 관리하기 쉽다. 기본적으로 스마트 포인터는 동적으로 할당한 모든 자원(resource)을 가리킨다. 스마트 포인터가 유효 범위를 벗어나거나 리셋되면 할당된 자원이 자동으로 해제된다. 스마트 포인터의 종류는 다양한데 이 절에서 자세히 소개한다. 이 포인터를 사용하려면 <memory> 헤더 파일을 선언해야 한다.
CAUTION 자원을 할당받으면 곧바로 unique_ptr나 shared_ptr와 같은 스마트 포인터에 저장하거나 다른 RAII(resource acquisition is initialization) 클래스를 사용한다. RAII 클래스는 어떤 자원의 소유권을 받아서 이를 적절한 시점에 해제하는 작업을 한다.
7.4.1 unique_ptr
스마트 포인터에서 가장 간단한 것은 자원에 대한 고유 소유권을 갖는 것이다. 그래서 스마트 포인터가 스코프를 벗어나거나 리셋되면 참조하던 리소스를 해제한다. 표준 라이브러리에서 제공하는 std::unique_ptr가 이러한 고유 소유권 방식(unique ownership)을 지원한다.
■ 7.4.1.1 unique_ptr 생성 방법
다음과 같이 Simple 객체를 unique_ptr로 구현하면 객체에 대해 delete를 직접 호출하지 않아도 된다. unique_ptr 인스턴스가 유효 범위를 벗어나면 소멸자가 호출될 때 Simple 객체가 자동으로 해제된다.
auto simpleSmartPtr = make_unique<Simple>();
또는
unique_ptr<Simple> simpleSmartPtr(new Simple());
C++17 이전에는 안전을 위해 반드시 make_unique를 사용했다. 예를 들어 다음과 같이 foo 함수를 호출하는 코드를 살펴보자. Simple이나 Bar 생성자 또는 data 함수에서 익셉션이 발생하면 Simple이나 Bar 객체에서 메모리 누수가 발생할 가능성이 있다. 하지만 make_unique를 사용하면 누수가 발생하지 않는다. 물론 C++17부터는 두 코드 모두 안전하게 처리된다.
foo(unique_ptr<Simple>(new Simple()), unique_ptr<Bar>(new Bar(data()))); /* C++17 이전은 메모리가 누수될 수 있습니다. */
또는
foo(make_unique<simple>(), make_unique<Bar>(data()));
NOTE unique_ptr를 생성할 때는 항상 make_unique를 사용한다.
■ 7.4.1.2 unique_ptr 사용 방법
① 역참조
unique_ptr는 일반 포인터와 똑같이 *나 ->로 역참조한다. 예를 들어 go 메서드를 호출할 때 ->연산자를 사용한다.
simpleSmartPtr->go();
(*simpleSmartPtr).go();
② get 함수
get을 사용하면 내부 포인터에 직접 접근할 수 있다. 일반 포인터만 전달할 수 있는 함수에 스마트 포인터를 전달할 때 유용하다.
void foo(Simple* simple) { ... } /* 일반 포인터를 사용하는 함수 */
auto simpleSmartPtr = make_unique<Simple>();
foo(simpleSmartPtr.get()); /* 스마트 포인터의 일반 포인터를 전달합니다. */
③ reset 함수
reset을 사용하면 unique_ptr의 내부 포인터를 해제하고 이를 다른 포인터로 변경할 수 있다. 예를 들면 다음과 같다.
simpleSmartPtr.reset(); /* 자원 해제 후 nullptr로 초기화합니다. */
simpleSmartPtr.reset(new Simple()); /* 자원 해제 후 새로운 Simple 인스턴스를 설정합니다. */
④ release 함수
release를 사용하면 unique_ptr와 내부 포인터의 관계를 끊을 수 있다. release 메서드는 자원을 가리키는 내부 포인터를 반환한 후 스마트 포인터를 nullptr로 설정한다. 그러면 사용자는 자원을 다 쓴 후 반드시 자원을 직접 해제해야 한다. 예를 들면 다음과 같다.
Simple* simple = simpleSmartPtr.release(); /* 자원 소유권을 해제한다. */
...
delete simple;
simple = nullptr;
⑤ 소유권 이전
unique_ptr는 단독 소유권을 표현하기 때문에 명시적으로 복사 생성자가 삭제되어 복사를 할 수 없다. 하지만 std::move를 사용하면 unique_ptr를 다른 곳으로 이동시킬 수 있다. 다음과 같이 소유권을 명시적으로 이전하는 데 많이 사용한다.
class Foo {
public:
Foo(unique_ptr<int> data) : mData(move(data)) { }
private:
unique_ptr<int> mData;
};
auto myUniquePtr = make_unique<Foo>(10);
Foo f(move(myUniquePtr)); /* myUniquePtr의 소유권을 mData로 이전합니다. */
■ 7.4.1.3 unique_ptr의 C 스타일 배열
unique_ptr는 C 스타일의 동적 할당 배열을 저장할 수 있다. 예를 들어, 정수 10개를 가진 C 스타일의 동적 할당 배열은 다음과 같이 표현할 수 있다.
auto myArray = make_unique<int[]>(10);
■ 7.4.1.3 unique_ptr 커스텀 제거자
기본적으로 unique_ptr는 new와 delete로 메모리를 할당하거나 해제한다. 하지만 다음과 같이 방식을 변경할 수 있다.
int* malloc_int(int value) {
int* p = (int*)malloc(sizeof(int));
*p = value;
return p;
}
int main(void) {
unique_ptr<int, decltype(free)*> myUniquePtr(malloc_int(10), free); /* 커스텀 제거자를 도입합니다. */
return 0;
}
이 코드는 malloc_int로 정수에 대한 메모리를 할당하고 free로 해제한다. unique_ptr에서 이 기능을 제공하는 이유는 메모리가 아닌 다른 리소스(파일, 네트워크 소켓 등)를 관리하는 데 유용하기 때문이다.
unique_ptr는 커스텀 제거자(custom deleter)의 타입을 템플릿 타입 매개 변수로 지정한다. 템플릿 타입 매개 변수는 함수에 대한 포인터 타입이어야 하므로 decltype(free)*와 같이 *를 더 붙인다.
7.4.2 shared_ptr
어떤 포인터의 복사본을 여러 객체나 코드에서 갖고 있을 때가 있다. 이러한 상황을 앨리어싱(aliasing)이라 부른다. 모든 리소스를 제대로 해제하려면 리소스를 마지막으로 사용한 포인터가 해제해야 한다. 그래서 리소스의 마지막 소유자를 추적하도록 참조 횟수 계산 방식(reference counting)을 구현한 스마트 포인터가 있다. 레퍼런스 카운트가 0이 되면 그 리소스를 사용하는 곳이 없기 때문에 자동으로 해제된다. 표준 라이브러리에서 제공하는 std::shared_ptr가 이러한 공유 소유권 방식(shared ownership)을 지원한다.
■ 7.4.2.1 shared_ptr 생성 방법
shared_ptr는 make_shared로 생성한다. 예를 들면 다음과 같다.
auto simpleSmartPtr = make_shared<Simple>();
또는
shared_ptr<Simple> simpleSmartPtr(new Simple());
NOTE shared_ptr를 생성할 때는 항상 make_shared를 사용한다.
■ 7.4.2.2 shared_ptr 사용 방법
① 역참조
shared_ptr는 일반 포인터와 똑같이 *나 ->로 역참조한다. 예를 들어 go 메서드를 호출할 때 ->연산자를 사용한다.
simpleSmartPtr->go();
(*simpleSmartPtr).go();
② get 함수, reset 함수
shared_ptr도 get과 reset 메서드를 제공한다. 다른 점은 reset을 호출하면 레퍼런스 카운팅 메커니즘에 따라 마지막 shared_ptr가 제거되거나 리셋될 때 리소스가 해제된다.
void foo(Simple* simple) { ... } /* 스마트 포인터를 사용하는 함수 */
auto simpleSmartPtr = make_shared<Simple>();
foo(simpleSmartPtr.get()); /* 스마트 포인터의 일반 포인터를 전달합니다. */
③ use_count 함수
현재 같은 리소스를 공유하는 shared_ptr의 개수는 use_count로 알아낼 수 있다.
■ 7.4.2.3 shared_ptr 커스텀 제거자
shared_ptr는 메모리 할당 및 해제에 new와 delete 연산자를, C++17부터 C 스타일 배열을 저장할 때는 new[]와 delete[]를 기본으로 사용한다. 이러한 동작은 다음과 같이 변경할 수 있다.
int* malloc_int(int value) {
int* p = (int*)malloc(sizeof(int));
*p = value;
return p;
}
int main(void) {
shared_ptr<int> mySharedPtr(malloc_int(10), free); /* 커스텀 제거자를 도입합니다. */
return 0;
}
여기서 shared_ptr는 unique_ptr와 같이 커스텀 제거자의 타입을 템플릿 타입 매개 변수로 전달하지 않아도 된다.
■ 7.4.2.4 레퍼런스 카운팅
레퍼런스 카운팅(reference counting)은 어떤 클래스의 인스턴스 수나 사용 중인 객체를 추적하는 메커니즘이다. 레퍼런스 카운팅을 지원하는 스마트 포인터는 포인터를 참조하는 스마트 포인터를 추적한다.
7.4.3 weak_ptr
weak_ptr는 shared_ptr가 가리키는 리소스의 레퍼런스를 관리하는 스마트 포인터다. weak_ptr는 리소스를 소유하지 않기 때문에 리소스를 할당받거나 해제해도 shared_ptr의 레퍼런스 카운팅에 아무런 영향을 주지 않는다. 다시 말해, weak_ptr는 더 이상 사용되지 않을 때 가리키던 리소스를 해제하지 않는다. weak_ptr는 shared_ptr나 다른 weak_ptr를 인수로 하는 복사 생성자와 복사 대입 연산자를 통해 리소스에 대한 참조를 할당받는다. weak_ptr는 할당받은 리소스를 사용하려면 shared_ptr로 변환하여 접근해야 한다. 그 방법은 다음과 같다.
- weak_ptr 인스턴스의 lock 메서드를 사용하여 shared_ptr를 반환받는다.
- shared_ptr의 복사 생성자에 weak_ptr를 인수로 전달해서 shared_ptr를 생성한다.
void useResource(weak_ptr<Simple>& weakSimple) {
auto resource = weakSimple.lock();
if (resource) {
std::cout << "자원이 아직 해제되지 않았습니다." << std::endl;
}
else {
std::cout << "자원이 해제되었습니다." << std::endl;
}
}
int main(void) {
auto sharedSimple = make_shared<Simple>();
weak_ptr<Simple> weakSimple(sharedSimple); /* 복사 생성자로 weak_ptr<Simple>객체를 생성합니다. */
useResource(weakSimple);
sharedSimple.reset(); /* shared_ptr<Simple> 객체를 리셋합니다. */
useResource(weakSimple);
return 0;
}
자원이 아직 해제되지 않았습니다.
자원이 해제되었습니다.
7.4.4 auto_ptr
C++11 이전에는 표준 라이브러리에서 스마트 포인터를 간단히 구현한 auto_ptr를 제공했는데 vector와 같은 표준 라이브러리 컨테이너 안에서는 제대로 작동하지 않는 등 심각한 단점이 있었다. C++17부터 auto_ptr는 완전히 삭제되었다.
CAUTION 기존 스마트 포인터인 auto_ptr를 절대로 사용하지 말고 unique_ptr나 shared_ptr를 사용한다.
7.4.5 enable_shared_from_this
std::enable_shared_from_this 믹스인 클래스를 이용하면 객체의 메서드에서 shared_ptr나 weak_ptr를 안전하게 리턴할 수 있다. enable_shared_from_this 믹스인 클래스는 다음 두 메서드를 클래스에 제공한다.
- shared_from_this: 객체의 소유권을 공유하는 shard_ptr를 리턴한다.
- weak_from_this: C++17부터 객체의 소유권을 추적하는 weak_ptr를 리턴한다.
class Foo : public enable_shared_from_this<Foo> {
public:
shared_ptr<Foo> getPointer() {
return shared_from_this();
}
};
int main(void) {
auto ptr1 = make_shared<Foo>();
auto ptr2 = ptr1->getPointer();
}
7.5 흔히 발생하는 메모리 문제
7.5.1 문자열 과소 할당 문제
C 스타일 문자열에서 가장 흔히 발생하는 문제는 과소 할당(underallocation)이다. 이 문제는 주로 문자열 끝을 나타내는 널문자('\0')가 들어갈 공간을 잊어버리고 메모리를 할당할 때 발생한다. 또한, 문자열의 최대 크기를 특정한 값으로 미리 정해두고 문자열에 할당된 메모리 공간을 넘어갈 때도 발생한다. 과소 할당 문제를 해결하는 방법은 다음 세 가지다.
- C++ 스타일 문자열을 사용한다. 그러면 문자열을 연결하는 작업에 필요한 메모리를 알아서 관리해준다.
- 버퍼를 전역 변수나 스택(로컬) 변수로 만들지 말고 힙에 할당한다. 공간이 부족하면 필요한 만큼 추가로 할당하고, 원본 버퍼를 새 퍼버로 복사한 후 문자열을 연결한다. 그러고 나서 원본 버퍼를 삭제한다.
- 널문자를 포함한 최대 문자수를 입력받아서 그 길이를 넘어선 부분은 리턴하지 않고, 현재 버퍼에 남은 공간과 현재 위치를 항상 추적한다.
7.5.2 메모리 경계 침범
포인터는 단지 메모리 주소일 뿐이며 메모리에서 아무 곳이나 가리킬 수 있다. 예를 들어, 문자열의 모든 문자를 'a'로 바꾸는 함수가 있다고 가정해보자. 이 함수에 문자열의 끝을 나타내는 널문자('\0')가 누락된 문자열을 입력하면 널문자를 만났을 때 루프를 빠져나오는 종료 조건을 만족하지 못하기 때문에 문자열에 할당된 공간을 넘어서 다른 중요한 영역까지 'a'로 덮어쓴다. 이러한 문제가 문자열이 아닌 배열에 발생하는 것을 흔히 버퍼 오버플로 에러(buffer overflow error)라 부른다.
CAUTION
아무런 보호 장치도 제공하지 않는 C 스타일 문자열이나 배열을 사용하지 말고 string이나 vector처럼 메로리 관리 기능이 있는 최신 C++ 기능을 활용한다.
7.5.3 메모리 누수
메모리 누수 현상은 할당했던 메모리를 제대로 해제하지 않을 때 발생한다. 조금만 주의를 기울이면 쉽게 해결될 거라고 여기기 쉽지만, new에 대응되는 delete를 빠짐없이 작성하더라도 누수 현상이 발생하는 경우도 있다. 이럴 때는 가장 먼저 스마트 포인터를 도입하는 것이 좋다.
7.5.4 중복 삭제와 잘못된 포인터
포인터에 할당된 메모리를 delete로 해제했는데 그 포인터를 계속 쓰는 것을 댕글링 포인터(dangling pointer)라 부른다. 이때 이 포인터에 delete를 또 적용하면 이미 다른 객체를 할당한 메모리를 해제해 버리는 중복 삭제 문제가 발생한다. 스마트 포인터 대신 일반 포인터를 사용하려면 메모리를 해제한 후 포인터를 nullptr로 초기화하는 작업을 반드시 하길 바란다.
'Object Oriented Programming(C++) > 전문가를 위한 C++' 카테고리의 다른 글
전문가를 위한 C++ | Chapter 10 상속 활용하기(중) (0) | 2022.02.07 |
---|---|
전문가를 위한 C++ | Chapter 10 상속 활용하기(상) (0) | 2022.01.30 |
전문가를 위한 C++ | Chapter 07 메모리 관리(상) (0) | 2022.01.20 |
전문가를 위한 C++ | Chapter 09 클래스와 객체 마스터하기(하) (0) | 2022.01.16 |
전문가를 위한 C++ | Chapter 09 클래스와 객체 마스터하기(상) (0) | 2022.01.14 |
댓글