- C++는 언어 자체가 굉장히 유연하고 안전성보다는 성능을 추구하기 때문에 심각한 문제가 발생할 가능성도 높다.
- 메모리 할당과 관리는 C++ 프로그래밍에서 특히 문제가 발생하기 쉬운 영역이다. 따라서 메모리 관리의 내부 작동 방식을 확실히 이해하고 있어야 한다.
- 모던 C++코드에서는 로우레벨 메모리 연산은 가급적 피하고 컨테이너나 스마트 포인터와 같은 최신 기능을 활용하는 추세다
7.1 동적 메모리 다루기
7.1.1 메모리의 작동 과정 살펴보기
int i { 7 };
- 로컬 변수 i를 자동 변수라고 부르며 스택에 저장된다.
- 프로그램의 실행 흐름이 이 변수가 선언된 스코프(유효 범위)를 벗어나면 할당된 메모리가 자동으로 해제된다.
- new 키워드를 사용하면 프리스토어(자유 공간)에 메모리가 할당된다.
- 항상 포인터 변수를 선언하자마자 nullptr이나 적절한 포인터로 초기화해야 한다. 절대로 초기화하지 않은 상태로 내버려두지 말자.
int** handle { nullptr };
handle = new int*;
*handle = new int;
- handle은 스택에 있고, *handle은 프리스토어에 있도록 두 단계로 구성한 상태이다.
7.1.2 메모리 할당과 해제
- 변수가 사용할 공간은 new 키워드로 생성한다. 다 사용했다면 이 공간을 프로그램의 다른 영역에서 사용할 수 있도록 delete 키워드로 해제한다.
+ 1. new와 delete 사용법
- 변수에 필요한 메모리 블록을 할당하려면 new에 그 변수의 타입을 지정해서 호출한다. 그러면 할당된 메모리에 대한 포인터가 리턴된다.
- new의 리턴값을 무시하거나 그 포인터를 담았던 변수가 스코프를 벗어나게 되면 할당된 메모리에 접근할 수 없게 된다.(미아가 된다.) 이를 메모리 누수(메모리 릭)라 부른다.
- 속도가 빠른 메모리를 무한 공급하지 않는 한 객체에 할당했던 메모리를 해제해야 다른 용도로 사용할 수 있다. 프리스토어 메모리를 해제하려면 다음과 같이 delete 키워드에 해제할 메모리를 가리키는 포인터를 지정한다.
- new로 메모리를 할당할 때 스마트 포인터가 아닌 일반 포인터로 저장했다면 반드시 그 메모리를 해제하는 delete문을 new와 짝을 이루도록 작성해야 한다.
- 메모리를 해제한 포인터는 nullptr로 초기화한다. 그래야 이미 해제된 메모리를 가리키는 포인터를 모르고 다시 사용하는 실수를 방지할 수 있다. 참고로 nullptr로 초기화한 포인터에 대해 delete를 호출해도 문제가 발생하지 않는다. 그저 아무 일도 일어나지 않을 뿐이다.
+ 2. malloc()
- C에서 malloc()은 인수로 지정한 바이트 수만큼 메모리를 할당한다.
- malloc() 함수는 메모리에서 일정한 영역만 따로 빼놓을 뿐 객체에 대해 알지도 못하고 관심도 없다.
- C++에서는 malloc()을 지원하지만 malloc() 대신 new를 사용하는 것이 바람직하다. new는 단순히 메모리를 할당하는 데 그치지 않고 객체까지 만들기 빼문이다.
- new를 호출한 문장은 적절한 크기의 메모리 공간이 할당될 뿐만 아니라 생성자를 호출해서 객체를 생성한다.
- free()와 delete의 관계도 이와 비슷하다. free()는 객체의 소멸자를 호출하지 않는 반면 delete는 소멸자를 호출해서 객체를 정상적으로 제거한다.
- C++에서는 malloc()과 free()를 절대 사용하지 말고 new와 delete만 사용한다.
+ 3. 메모리 할당에 실패한 경우
- new는 메모리가 부족해서 상황이 무지무지 좋지 않을 때만 new가 실패한다. 기본적으로 new가 실패하면 익셉션을 던진다. 요청한 만큼 메모리가 없을 때 그렇다. 이때 발생한 익셉션을 잡지 않으면 프로그램이 종료된다. 대부분의 경우는 이렇게 해도 괜찮다.
- 익셉션을 던지지 않는 버전의 new도 있다. 이 버전은 익셉션 대신 nullptr을 리턴한다. 마치 C에서 malloc()을 호출할 때와 같다.
int* ptr { new(nothrow) int };
- 메모리가 부족해서 nullptr을 리턴한다면 해당 값을 검사하지 않더라도 컴파일 에러가 발생하지 않는다. 그러므로 익셉션을 던지는 버전보다 nothrow 버전을 사용할 때 버그가 발생할 가능성이 높다.
7.1.3 배열
- 배열(array)은 타입이 같은 원소들을 변수 하나에 담아서 각각을 인덱스로 구분한다.
+ 1. 기본 타입 배열
- 프로그램에서 배열에 대해 할당한 메모리는 실제로도 연달아 붙어 있다. 이때 메모리 한 칸의 크기는 배열을 구성하는 원소 하나를 담을 수 잇는 크기다.
- 선언할 때는 배열의 크기를 컴파일 시간에 결정할 수 있도록 상숫값으로 지정해야 한다.
- 배열의 각 원소를 초기화하는 데 초기자 리스트를 사용할 수 있다. 초기자 리스트를 사용하면 원소 개수를 컴파일러가 알아내기 때문에 배열 선언에 크기를 명시적으로 적지 않아도 된다.
- 이때 초기자에 담긴 원소 개수가 배열의 크기보다 적다면 나머지는 0으로 초기화된다.
- { 0 };으로 작성하면 원소 전체를 한 번에 모두 0으로 초기화할 수 있다.
- 이때 0을 생략하여 모든 원소를 영으로 초기화할 수도 있다.
- new[]를 호출한 횟수만큼 delete[]를 호출해야 배열에 할당했던 메모리가 제대로 해제된다.
- 배열을 프리스토어에 할당하면 배열의 크기를 실행 시간에 정할 수 있다는 장점이 있다.
- 동적으로 할당된 배열과 동적 배열은 다르다. 배열을 할당하고 나면 원소 개수가 변하지 않기 때문에 동적이지 않다.
- 동적 메모리는 할당된 블록의 크기를 실행 시간에 지정할 수는 있지만 더 많은 데이터를 담을 수 있게 크기를 자동으로 조절할 수는 없다.
- C++는 realloc()이란 함수도 지원한다. 이 함수 역시 C언어로부터 물려받은 것이다. 따라서 절대 사용하지 말기 바란다.
- C에서 realloc()은 새로 지정한 크기에 맞게 메모리 블록을 새로 할당하는 방식으로 배열의 크기를 동적으로 조절한다. 이 과정에서 기존 데이터를 새 블록으로 복사하고, 원래 블록은 삭제한다.
- C++에서 이렇게 처리하면 굉장히 위험하다. 사용자가 정의한 객체는 비트 단위 복사 작업에 맞지 않기 때문이다.
+ 2. 객체 배열
- 객체 배열도 기본 타입 배열과 비슷하다. 원소를 초기화하는 방식만 다를 뿐이다.
- 객체 배열에서 new[]를 호출하면 배열을 구성하는 각 객체마다 영 인수(디폴트) 생성자가 호출된다. 디폴트로 원소를 초기화하지 않는 기본 타입 배열과 대조적이다. 이처럼 객체 배열을 new[]로 할당하면 형식에 맞게 초기화된 객체 배열을 가리키는 포인터가 리턴된다.
+ 3. 배열 삭제하기
- 배열에 대한 메모리를 new[]로 할당하면 반드시 new[]를 호출한 수만큼 delete[]를 호출해서 메모리를 해제해야 한다. 그러면 할당된 메모리를 해제할 뿐만 아니라 각 원소의 객체마다 소멸자를 호출한다.
- 배열 버전인 delete[]를 사용하지 않으면 프로그램이 이상하게 동작할 수 있다. 어떤 컴파일러는 객체를 가리키는 포인터만 삭제한다고 여기고 배열의 첫 번째 원소에 대한 소멸자만 호출해서 나머지 원소는 미아가 되어버린다.
- 또 어떤 컴파일러는 new와 new[]의 메모리 할당 방식이 서로 전혀 달라서 메모리 손상이 발생하기도 한다.
- new로 할당한 것을 해제할 때는 반드시 delete를 사용하고, new[]로 할당한 것을 해제할 때는 반드시 delete[]를 사용한다.
- 물론 배열의 원소가 객체일 때만 소멸자가 호출된다. 포인터 배열에 대해 delete[]를 호출할 때는 각 원소가 가리키는 객체를 일일이 해제해야 한다.
+ 4. 다차원 배열
- 다차원 배열이란 인덱스를 여러 개 사용하도록 확장한 배열이다.
| 다차원 스택 배열
- 스택에 생성한 이차원 배열의 메모리 상태는 두 개의 축을 사용하지 않고 일차원 배열처럼 나열되어 있다. 배열의 크기와 이를 접근하는 방식만 다르다.
| 다차원 프리스토어 배열
- 다차원 배열에서 차원 수를 실행 시간에 결정하고 싶다면 프리스토어 배열로 생성한다.
- 프리스토어 배열에 대한 메모리 할당 방식은 스택 배열과 다르기 때문에 프리스토어에서는 메모리 공간이 연속적으로 할당되지 않기 때문에 스택 방식의 다차원 배열처럼 메모리를 할당하면 안 된다. 이럴 때는 프리스토어 배열의 첫 번째 인덱스에 해당하는 차원의 배열을 연속적인 공간에 먼저 할당한다. 그런 다음 이 배열의 각 원소에 두 번째 인덱스에 해당하는 차원의 배열을 가리키는 포인터를 저장한다.
- 다차원 프리스토어 배열에 할당된 메모리를 해제할 때도 마찬가지다. delete[]가 하위 배열까지 해제해주지 않기 때문에 직접 해제해야 한다.
- 위에서 본 다차원 배열 할당 예는 효율적인 방법이라고 볼 수 없다. 여기에서는 먼저 첫 번째 차원에 대한 메모리를 할당하고 나서 하위 배열에 대한 메모리를 할당했다. 이렇게하면 메모리 블록이 여러 곳에 흩어지게 되므로 데이터 구조를 처리하는 알고리즘의 성능에 영향을 미치게 된다. 알고리즘의 성능은 연속된 메모리를 처리할 때가 더 좋다.
- 한 가지 해결 방법은 xDimension * yDimension * elementSize 만큼의 거대한 메모리 블록 하나를 할당하고, x * yDimension + y와 같은 수식으로 각 원소를 접근하는 것이다.
- C 스타일 배열 대신 std::array나 std::vector와 같은 C++ 표준 라이브러리에서 제공하는 컨테이너를 사용한다.
7.1.4 포인터 다루기
- 포인터는 남용하기 쉽기로 악명 높다. 포인터는 메로리 주소일 뿐 그 주소를 얼마든지 변경할 수 있고, 심지어 다음과 같이 위험한 일도 할 수 있다.
char* scaryPointer { (char*)7 };
- new를 호출하거나 스택에 생성된 것처럼 별도로 할당된 것이 아닌 메모리 공간을 사용하면 객체를 저장하거나 프리스토어 관리에 사용되는 메모리가 손상되어 프로그램이 제대로 작동하지 않게 된다. 이러한 현상은 다양한 형태로 나타난다.
+ 1. 포인터의 작동 방식
- 포인터는 메모리의 한 지점을 가리키는 숫자에 불과하다.
- *연산자로 포인터를 역참조하면 메모리에서 한 단계 더 들어가 볼 수 있다. 포인터를 주소 관점에서 보면 역참조는 포인터가 가리키는 주소로 점프하는 것과 같다. 역참조를 하는 부분을 그림으로 표현하면 출발 지점에서 목적지로 향하는 화살표로 나타낼 수 있다.
- &연산자를 사용하여 특정 지점의 주소를 구하면 메모리에 대한 참조 단계가 하나 더 늘어난다. 이 연산자를 주소 관점으로 보면 특정 지점을 숫자로 표현한 주소로 본다. 공간 관점에서 보면 &연산자는 표현식으로 지정한 지점을 가리키는 화살표를 생성한다고 볼 수 있다. 그리고 이 화살표가 시작하는 지점을 포인터로 저장할 수 있다.
+ 2. 포인터에 대한 타입 캐스팅
- 포인터는 단지 메모리 주소(또는 어떤 지점을 가리키는 화살표)에 불과해서 타입을 엄격히 따지지 않는다.
- XML 문서를 가리키는 포인터와 정수를 가리키는 포인터의 크기는 서로 같다.
- 포인터의 타입은 C스타일 캐스팅으로 얼마든지 바꿀 수 있다.
- 정적 캐스팅을 사용하면 좀 더 안전하다. 관련없는 데이터 타입으로 포인터를 캐스팅하면 컴파일 에러가 발생한다.
- 상속 관계에 있는 대상끼리 캐스팅할 때는 동적 캐스팅을 사용하는 것이 더 안전하다.
7.2 배열과 포인터의 두 얼굴
7.2.1 배열 = 포인터
- 프리스토어 배열을 참조할 때만 포인터를 사용하는 것은 아니다. 스택 배열에 접근할 때도 포인터를 사용할 수 있다.
- 배열의 주소는 인덱스 0인 첫 번째 원소에 대한 주소다. 그러므로 프리스토어 배열과 똑같은 방식으로 포인터를 사용할 수 있다.
- 스택 배열을 포인터로 접근하는 기능은 배열을 함수에 넘길 때 특히 유용하다.
- 프리스토어 배열을 전달하면 이미 포인터가 있어서 함수에 값으로 전달된다.
- 스택 배열의 경우 호출하는 측에서 배열 변수를 전달하면 컴파일러가 알아서 배열에 대한 포인터로 변환한다. 또는 직접 첫 번째 배열의 주소로 넘겨도 된다.
- 배열을 매개변수로 전달하는 과정은 포인터를 매개변수로 전달하는 과정과 놀라울 정도로 비슷하다.
- 컴파일러는 배열을 함수로 전달하는 부분을 포인터로 취급한다. 배열을 인수로 받아서 그안에 담긴 값을 변경하는 함수는 복사본이 나닌 원본을 직접 수정한다. 포인터와 마찬가지로 배열을 전달하면 실제로 레퍼런스 전달 방식의 효과가 나타난다. 함수에 전달한 값이 배열의 복사본이 아닌 원본을 가리키는 주소이기 때문이다.
- 배열 문법으로 선언한 배열은 포인터로도 접근할 수 있다. 그리고 컴파일러는 함수로 전달하는 배열을 항상 포인터로 취급한다.
- C++20부터는 함수에 C스타일 배열을 전달할 때 직접 C스타일 배열로 전달하지 말고, std::span 타입 매개변수로 전달하는 것이 좋다. 이 타입은 배열에 대한 포인터와 배열 크기를 감싸준다.
7.2.2 포인터가 모두 배열은 아니다!
- 포인터와 배열은 비슷한 점이 많지만 똑같은 것은 아니다.
- 포인터 자체만으로는 아무런 의미가 없다. 임의의 메모리를 가리킬 수도 있고 객체나 배열을 가리킬 수도 있다. 언제든지 포인터에 배열 문법을 적용해도 되지만 실제로 포인터가 배열을 아니기 때문에 말이 안 되는 경우도 있다.
int* ptr { new int };
- 배열이 아닌 포인터를 배열 문법으로 표현하면 버그가 발생하기 쉽다.
- 모든 배열은 포인터로 참조할 수 있지만, 그렇다고 포인터가 배열인 것은 아니다.
- 배열의 크기는 컴파일 타임에 결정되어 크기를 변경할 수 없고,
포인터는 동적으로 메모리를 할당 받으며 메모리의 크기를 동적으로 변경할 수 있다.
- 배열은 주로 고정된 크기의 데이터 집합을 저장할 때 사용하며,
포인터는 배열의 크기를 동적으로 변경하거나, 함수에 배열을 전달할 때 주로 사용한다.
7.3 로우레벨 메모리 연산
7.3.1 포인터 연산
- C++ 컴파일러는 포인터 연산을 수행할 때 포인터에 선언된 타입을 적용한다.
- 포인터를 int로 선언하고 그 값을 1만큼 증가시키면 포인터는 메모리에서 한 바이트가 아닌 int크기만큼 이동한다.
- 이 연산은 주로 배열을 다루는 데 유용하다. 배열에 담긴 데이터는 모두 타입이 같을 뿐만 아니라 메모리에 연속적으로 저장되어 있기 때문이다.
- 개별 원소를 접근하는 용도로 포인터 연산이 적합하다고 보긴 힘들다. 포인터 연산의 강점은 myArray + 2와 같은 표현식에서도 여전히 포인터이기 때문에 더 작은 정수 배열을 표현할 수 있다는 것이다.
- 와이드 스트링은 유니코드 문자를 지원해서 한국어와 같은 다국어를 표현할 수 있다는 정도만 알면 된다.
- wchar_t 타입은 유니코드 문자를 지원하는 문자 타입 중 하나로, 대체로 char 타입보다 크다(즉, 한 바이트보다 크다).
- 주어진 스트링 리터럴이 와이드 스트링임을 컴파일러에 알려주려면 그 앞에 L을 붙인다.
const wchar_t* myString { L"Hello, World" };
- 포인터 연산에서는 뺄셈도 유용하다. 예를 들어 타입이 같은 두 포인터에 대해 뺄셈을 하면 두 포인터 사이에 몇 바이트가 있는지가 아니라 포인터에 지정한 타입의 원소가 몇 개 있는지 알 수 있다.
7.3.2 커스텀 메모리 관리
- 99%의 경우 C++에서 기본으로 제공하는 메모리 할당 기능만으로도 충분하다.
- new와 delete의 내부 처리 과정을 살펴보면 메모리를 적절한 크기로 잘라서 전달하고, 현재 메모리에서 사용할 수 있는 공간을 관리하고, 다 쓴 메모리를 해제하는 작업을 수행한다.
- 리소스가 상당히 부족하거나 공유 메모리 관리나 커스텀 메모리 구현과 같은 특수한 작업을 수행할 때는 메모리를 직접 다뤄야 한다. 핵심은 클래스에 큰 덩어리의 메모리를 할당해놓고 필요할 때마다 잘라 쓰는 것이다.
- 이렇게 직접 관리하면 오버헤드를 좀 더 줄일 수 있다.
- 메모리를 직접 다룰 때 객체 크기를 사전에 알고 있다면 이러한 오버헤드를 줄일 수 있다. 크기가 작은 객체가 아주 많을수록 오버헤드 절약 효과가 크다.
7.3.3 가비지 컬렉션
- 가비지 컬렉션을 제공하는 환경이라면 프로그래머가 객체에 할당된 메모리를 직접 해제할 일이 거의 없다.
- 더 이상 참조하지 않는 객체는 런타임 라이브러리가 일정한 시점에 제거해준다.
- C++는 자바난 C#과 달리 가비지 컬레션을 제공하지 않는다. 모던 C++에서는 스마트 포인터로 메모리를 관리할 수 있도록 개선되었지만 예전에는 new와 delete를 이용하여 객체 수준에서 직접 메모리를 관리해야 했다.
- 가비지 컬렉션을 구현하는 기법 중에 표시 후 쓸기(mark and sweep)란 알고리즘이 있다.
- 이 방식에 따르면 가비지 컬렉터가 프로그램에 있는 모든 포인터를 주기적으로 검사해서 참조하는 메모리를 계속 사용하는지 여부를 표시한다. 한 주기가 끝날 시점에 아무런 표시가 없는 메모리는 더 이상 사용하지 않는 것으로 간주하고 해제한다.
- 이 알고리즘을 C++로 구현하기는 쉽지 않다. 자칫 잘못하면 delete를 사용하는 것보다 에러 발생 가능성이 더 높아진다.
- 가비지 컬렉션의 단점
- 가비지 컬렉터가 작동하는 동안 프로그램이 응답하지 않을 수 있다.
- 가비지 컬렉터는 비결정적 소멸자를 사용한다. 객체는 가비지 컬렉션되기 전에는 제거되지 않는다. 그러므로 객체가 스코프를 벗어나더라도 소멸자가 즉시 구동되지 않는다. 다시 말해 소멸자가 처리하는 리소스 정리 작업이 언제 수행될지 정확히 알 수 없다.
7.3.4 객체 풀
- 객체 풀은 재활용하는 것에 비유할 수 있다. 타입이 같은 객체 여러 개를 지속적으로 사용해야 하며, 객체 생성 오버헤드가 상당히 큰 상황에 적용하기 좋다.
7.4 흔히 발생하는 메모리 관련 문제
7.4.1 데이터 버퍼 과소 할당과 경계를 벗어난 메모리 접근
- C 스타일 스트링에서 가장 흔히 발생하는 문제로 과소 할당이 있다. 기본으로 제공되는 C 스타일 스트링 함수는 크기 제한이 없기 때문에 스트링에 할당된 메모리 공간을 얼마든지 넘어갈 수 있다.
- 과소 할당 문제를 해결하는 방법 세 가지
- C++ 스타일 스트링을 사용한다. 그러면 스트링을 연결하는 작업에 필요한 메로리를 알아서 관리해준다.
- 버퍼를 글로벌 변수나 스택(로컬) 변수로 만들지 말고 프리스토어 공간에 할당한다. 공간이 부족하면 현재 스트링보다 큰 공간을 추가로 할당하고, 원본 버퍼를 새 버퍼로 복사한 뒤, 두 스트링을 연결하고 나서 원본 버퍼를 삭제한다.
- 최대 문자 수(\0 포함)를 입력받아서 그 길이를 넘어선 부분은 리턴하지 않고, 현재 버퍼에 남은 공간과 현재 위치를 항상 추적하도록 getMoreDate()를 만든다.
- 배열의 경계를 벗어난 메모리 영역에 쓰게 되는 버그를 버퍼 오버플로 에러라 부른다.
- 아무런 보호 장치도 제공하지 않는 기존 C 스타일 스트링이나 배열을 사용하지 말고, string이나 vector처럼 메모리 관리 기능을 제공하는 안전한 모던 C++ 기능을 활용한다.
7.4.2 메모리 누수
- 메모리 누수는 작성한 프로그램이 의도한 대로 결과를 내다가 실행 횟수가 늘어날수록 메모리 공간을 잡아먹는다면 메모리 누수 현상이 발생한 것이다.
- 메모리 누수 현상은 할당했던 메모리를 제때 해제하지 않을 때 발생한다.
+ 1. 비주얼 C++를 이용한 윈도우 애플리케이션의 메모리 누수 탐지 및 수정 방법
- 마이크로소프트 비주얼 C++를 사용하고 있다면 디버그 라이브러리에서 기본으로 제공하는 메모리 누수 감지 기능을 활용할 수 있다. MFC 프로젝트를 생성하지 않는 한 이 기능은 기본적으로 꺼져 있다.
- 1. MFC 프로젝트가 아닌 경우에 이 기능을 사용하려면 코드의 첫머리에 다음 세 문장을 추가한다.
#define _CRTDBG_MAP_ALLOC
#include <cstdlib>
#include <crtdbg.h>
- 2. 또한 new 연산자를 다음과 같이 새로 정의한다.
#ifdef _DEBUG
#ifdef DBG_NEW
#define DBG_NEW new ( _NORMAL_BLOCK, __FILE__, __LINE__ )
#define new DBG_NEW
#endif
#endif // _DEBUG
- 3. main() 함수의 첫 부분에 다음 문장을 작성한다.
_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
- 애플리케이션이 종료할 때 비주얼 C++의 CRT(C RunTime) 라이브러리는 현재 감지된 모든 메모리 누수 현상을 디버그 콘솔에 출력한다.
- VC++에서 제공하는 _CrtSetBreakAlloc() 함수를 호출하면 메모리가 할당되는 순간 실행을 중단하고 디버거를 구동하도록 VC++ 디버그 런타임을 설정할 수 있다.
+ 2. 밸그린드를 이용한 리눅스 애플리케이션의 메모리 누수 탐지 및 해결 방법
- 밸그린드는 무료로 제공되는 리눅스용 오픈소스 도구로, 누수 객체에 대한 메모리 할당 지점을 줄 단위로 정확히 찾아준다.
- 메모리 누수 현상이 발생하지 않도록 std::vector, array, string. 스마트 포인터를 비롯한 모던 C++ 구문을 활용한다.
7.4.3 중복 삭제와 잘못된 포인터
- 포인터에 할당된 메모리를 delete로 해제하면 그 메모리를 프로그램의 다른 부분에서 사용할 수 있다. 하지만 이 상태에서도 여전히 그 포인터를 계속 사용할 수 있는데, 이를 댕글링 포인터라 부른다.
- 중복 삭제와 해제된 메모리의 재사용을 방지하려면 메모리를 해제한 후에는 항상 포인터값을 nullptr로 초기화한다.
7.5 스마트 포인터
- 동적으로 할당한 메모리를 여러 번 해제하면 메모리 상태가 손상되거나 치명적인 런타임 에러가 발생한다. 또한 동적으로 할당한 메모리를 깜빡 잊고 해제하지 않으면 메모리 누수 현상이 발생한다.
- 스마트 포인터를 사용하면 동적으로 할당한 메모리를 쉽게 관리할 수 있으므로 메모리 누수를 방지하기 위해 이를 적극 활용하는 것이 좋다.
- 기본적으로 스마트 포인터는 메모리뿐만 아니라 동적으로 할당한 모든 리소스를 가리킨다.
- 스마트 포인터가 스코프를 벗어나거나 리셋되면 할당된 리소스가 자동으로 해제된다.
- C++는 스마트 포인터를 지원하는 기능을 언어 차원에서 다양하게 제공한다.
- 첫째, 템플릿을 이용하면 모든 포인터 타입에 대해 타입에 안전한 스마트 포인터 클래스를 작성할 수 있다.
- 둘째, 연산자 오버로딩을 이용하여 스마트 포인터 객체에 대한 인터페이스를 제공해서 스마트 포인터 객체를 일반 포인터처럼 활용할 수 있다. 특히 *와 ->연산자를 오버로딩하면 스마트 포인터 객체를 일반 포인터처럼 역참조할 수 있다.
- 스마트 포인터의 종류는 다양하다.
- 가장 간단한 것은 리소스에 대한 단독 소유권을 가지는 것이다. 그러면 스마트 포인터가 스코프를 벗어나거나 리셋되면 참조하던 리소스를 자동으로 해제한다. 표준 라이브러리에서 제공하는 std::unique_ptr이 바로 이러한 단독 소유권을 제공한다.
- 한 리소스를 여러 스마트 포인터가 가리키는 공동 소유권을 제공하는 고급 기능도 있다. 이런 스마트 포인터는 스코프를 벗어나거나 리셋될 때 리소스를 마지막으로 가리키는 스마트 포인터만 그 리소스를 해제할 수 있다. 표준 라이브러리에서 제공하는 std::shared_ptr이 바로 이러한 공동 소유권을 제공한다.
- 주로 unique_ptr을 사용한다. shared_ptr은 리소스를 공유할 때만 사용한다.
- 리소스를 할당한 결과를 절대로 일반 포인터로 표현하면 안 된다. 어떤 방식으로 리소스를 할당했든지 반드시 그 결과를 곧바로 unique_ptr이나 shared_ptr과 같은 스마트 포인터에 저장하거나, RAII 클래스를 사용한다.
- RAII는 Resource Acquisition Is Initialization(리소스 획득이 곧 초기화)의 줄임말이다. RAII 클래스는 어떤 리소스의 소유권을 받아서 이를 적절한 시점에 해제하는 작업을 수행한다. 설계 기법 중 하나이다.
7.5.1 unique_ptr
- unique_ptr은 단독 소유권을 제공한다. unique_ptr이 제거되거나 리셋되면 이 포인터가 가리키던 리소스가 자동으로 해제된다.
- 이 포인터의 장점은 메모리나 리소스를 반드시 해제시킬 수 있다는 것이다. return 문을 실행하거나 익셉션이 발생했을 때도 해제된다.
- 일반적으로 동적으로 할당한 리소스는 unique_ptr 인스턴스에 저장하는 것이 바람직하다.
+ 1. unique_ptr 생성 방법
- unique_ptr은 std::make_unique() 헬퍼 함수로 생성할 수 있다.
- unique_ptr은 모든 종류의 메모리를 가리킬 수 있는 범용 스마트 포인터다. 그러므로 클래스 템플릿으로 구현되었고, make_unique()가 함수 템플릿으로 되어 있는 것이다.
- make_unique()는 값 초기화를 사용한다. 예를 들어 기본 타입은 0으로 초기화되고, 객체는 디폴트로 생성된다.
- 이러한 값 초기화 방식을 사용하고 싶지 않다면 C++20부터 추가된 make_unique_for_overwrite() 함수를 사용하면 된다.
- 이 함수는 디폴트 초기화를 사용한다. 기본 타입의 경우 초기화되지 않고 그 메모리 지점에 있던 값이 그대로 남아있다. 반면 객체는 디폴트로 생성된다.
void notLeaky()
{
auto mySimpleSmartPtr { make_unique<Simple>() };
mySimpleSmartPtr->go();
}
unique_ptr<Simple> mySimpleSmartPtr { new Simple{} };
// unique_ptr에서는 CTAD를 사용할 수 없으므로 템플릿 타입을 생략하면 안 된다.
- C++17 이전에는 타입을 단 한 번만 지정하기 위한 목적뿐만 아니라 안전을 이유로 반드시 make_unique()를 사용해야 한다. make_unique()를 사용하면 누수가 발생하지 않는다.
- 가독성을 감안하면 make_unique()를 사용하는 것이 낫다.
- unique_ptr을 생성할 때는 항상 make_unique()를 사용한다.
+ 2. unique_ptr 사용 방법
- 표준 스마트 포인터의 대표적인 장점은 문법을 새로 익히지 않고도 향상된 기능을 누릴 수 있다는 것이다. 스마트 포인터는 일반 포인터와 똑같이 *나 ->로 역참조한다.
- get() 메서드를 이용하면 내부 포인터에 직접 접근할 수 있다. 이는 일반 포인터만 전달할 수 있는 함수에 스마트 포인터를 전달할 때 유용하다.
- reset()을 이용하면 unique_ptr의 내부 포인터를 해제하고, 필요하다면 이를 다른 포인터로 변경할 수 있다.
- release()를 이용하면 unique_ptr과 내부 포인터의 관계를 끊을 수 있다. release() 메서드는 리소스에 대한 내부 포인터를 리턴한 뒤 스마트 포인터를 nullptr로 설정한다. 그러면 스마트 포인터는 그 리소스에 대한 소유권을 잃으며, 리소스를 다 쓴 뒤 반드시 직접 해제해야 한다.
- unique_ptr은 단독 소유권을 표현하기 때문에 복사할 수 없다. 하지만 std::move() 유틸리티 함수를 사용하면 이동 의미론을 적용하여 unique_ptr을 다른 곳으로 이동시킬 수 있다.
+ 3. unique_ptr과 C 스타일 배열
- unique_ptr은 C 스타일의 동적 할당 배열을 저장하는 데 적합하다.
- make_unique()는 배열이 아닐 때와 마찬가지로 배열의 모든 원소에 대해 값 초기화를 사용한다. 기본 타입의 경우 0으로 초기화한다.
- C++20부터는 디폴트 초깃값으로 배열을 생성할 때 make_unique_for_overwrite() 함수를 대신 사용할 수 있다.
- 이는 기본 타입에 대해서는 초기화하지 않는다는 것을 의미한다. 하지만 초기화되지 않은 데이터는 최대한 피하는 것이 바람직하므로 이 기능은 주의해서 사용해야 한다.
- unique_ptr로 C스타일의 동적 할당 배열을 저장할 수 있지만 std::array나 std::vector와 같은 표준 라이브러리 컨테이너를 사용하는 것이 바람직하다.
+ 4. 커스텀 제거자
- 기본적으로 unique_ptr은 new와 delete로 메모리를 할당하거나 해제한다. 하지만 방식을 변경할 수 있다.
- 커스텀 제거자란 객체를 삭제할 때 사용하는 사용자 정의 삭제 함수이다.
- unique_ptr로 커스텀 제거자를 작성하는 문법은 좀 지저분하다. 작성하는 커스텀 제거자의 타입을 템플릿 타입 매개변수로 지정하기 때문이다.
- shared_ptr로 커스텀 제거자를 작성하는 문장은 이보다 간단하다.
7.5.2 shared_ptr
- std::shared_ptr이란 스마트 포인터를 통해 복제 가능한 공유 소유권을 제공한다.
- 레퍼런스 카운팅(참조 횟구 계산)이란 기법을 통해 해당 리소스를 해제할 시점을 알아낼 수 있다.
+ 1. shared_ptr을 생성해서 사용하기
- shared_ptr은 make_shared()로 생성한다. 이렇게 하는 것이 shared_ptr을 직접 생성하는 것보다 훨씬 효율적이다.
auto mySimpleSmartPtr { make_shared<Simple>() };
- shared_ptr도 CTAD을 사용할 수 없다. 따라서 반드시 템플릿 타입을 명시해야 한다.
- make_shared()도 값 초기화를 사용한다. 값 초기화를 사용하고 싶지 않다면 C++20부터 제공하는 make_shared_for_overwrite()로 디폴트 초기화를 할 수 잇다.
- C++17부터 shared_ptr도 기존 C 스타일 동적 할당 배열에 대한 포인터를 저장할 수 있다. 하지만 shared_ptr에 C스타일 배열을 저장할 수 잇다 해도 표준 라이브러리 컨테이너를 사용하는 것이 바람직하다.
- shared_ptr도 get()과 reset() 메서드를 제공한다.
- 다만 reset()을 호출하면 레퍼런스 카운팅 메커니즘에 따라 마지막 shared_ptr이 제거되거나 리셋될 때 리소스가 해제된다는 점이 다르다. 참고로 release()를 지원하지 않는다.
- 현재 동일한 리소스를 공유하는 shared_ptr의 개수는 use_count()로 알아낼 수 있다.
- 커스텀 제거자의 타입을 템플릿 타입 매개변수로 지정하지 않아도 된다. 그러므로 unique_ptr로 커스텀 제거자를 작성할 때보다 훨씬 간편하다.
+ 2. 레퍼런스 카운팅이 필요한 이유
- 레퍼런스 카운팅은 클래스의 인스턴스 수나 현재 사용 중인 특정 객체를 추적하는 메커니즘이다.
- 레퍼런스 카운팅을 지원하는 스마트 포인터는 실제 포인터를 참조하는 스마트 포인터의 개수를 추적한다.
- 레퍼런스 카운터가 0에 다다르면 해당 리소스를 아무도 갖고 있지 않다는 뜻이므로 마지막 남은 스마트 포인터가 그 리소스를 해제한다.
- 레퍼런스 카운팅을 적용한 shared_ptr을 사용하면 중복 제거 문제를 피할 수 있다.
- 여러 shared_ptr 인스턴스가 동일한 메모리를 가리키게 하기 위한 안전한 방법은 shared_ptr 인스턴스를 그냥 복제하는 것뿐이다.
+ 3. shared_ptr 캐스팅하기
- 일반 포인터에서 다른 타입으로 캐스팅할 수 있듯이 shared_ptr도 지정된 것과 다른 타입으로 캐스팅할 수 있다. 물론 캐스팅할 수 있는 타입의 종류에 제약이 있다. 모든 타입을 지원하는 것은 아니다.
- shared_ptr에 대한 캐스팅을 지원하는 함수
- const_pointer_cast()
- dynamic_pointer_cast()
- static_pointer_cast()
- reinterpret_pointer_cast()
+ 4. 앨리어싱
- shared_ptr은 앨리어싱을 지원한다. 즉 한 포인터(소유한 포인터)를 다른 shared_ptr과 공유하면서 다른 객체(저장된 포인터)를 가리킬 수 있다.
- 예를 들어 shared_ptr이 어떤 객체를 소유하는 동시에 그 객체의 멤버도 가리키게 할 수 있다.
- 소유한 포인터는 레퍼런스 카운팅에 사용되지만, 저장된 포인터는 역참조하거나 get()을 호출하면 리턴된다.
- 모든 C++에서는 소유권과 상관없는 대상에 대해서만 일반 포인터를 사용한다. 만약 소유권에 관련이 있다면 기본적으로 unique_ptr을 사용하고, 이를 공유한다면 shared_ptr을 사용한다. 또한 이런 포인터를 생성할 때는 make_unique()와 make_shared()를 사용한다. 그러므로 new와 delete 연산자를 직접 호출할 일이 없다.
7.5.3 weak_ptr
- std::weak_ptr이란? std::shared_ptr랑 같이 쓰이는 스마트 포인터인데, 객체의 소유권은 갖지 않고 참조만 할 수 있다.
- shared_ptr끼리 서로 참조하면 순환 참조(circular reference) 때문에 메모리가 해제되지 않는 문제가 생기는데, 이걸 해결하기 위해 weak_ptr을 쓴다.
- weak_ptr은 shared_ptr이 관리하는 리소스에 대한 레퍼런스를 가질 수 있다. wake_ptr은 리소스를 직접 소유하지 않기 때문에 shared_ptr이 해당 리소스를 해제하는 데 아무런 영향을 미치지 않는다.
- wake_ptr은 삭제될 때 (예를 들어 스코프를 벗어날 때) 가리키던 리소스를 삭제하지 않지만, shared_ptr이 그 리소스를 해제했는지 여부를 확인하는 데 사용할 수 있다.
- wake_ptr의 생성자는 shared_ptr이나 다른 weak_ptr을 인수로 받는다. weak_ptr에 저장된 포인터에 접근하려면 shared_ptr로 변환해야 한다
- 변환 방법은 다음 두 가지다.
- weak_ptr 인스턴스의 lock() 메서드를 이용하여 shared_ptr을 리턴받는다. 이때 shared_ptr에 연결된 weak_ptr이 해제되면 shared_ptr의 값은 nullptr이 된다.
- shared_ptr의 생성자에 weak_ptr을 인수로 전달해서 생성한다. 이때 shared_ptr에 연결된 weak_ptr이 해제되면 std::bad_weak_ptr 익셉션을 던진다.
- C++17부터 weak_ptr도 shared_ptr과 마찬가지로 C스타일 배열을 지원한다.
7.5.4 함수에 전달하기
- 매개변수에서 포인터를 받는 함수는 소유권을 전달하거나 공유할 경우에만 스마트 포인터를 사용해야 한다.
- shared_ptr의 소유권을 공유하려면 shared_ptr을 값으로 전달받으면 된다. 마찬가지로 unique_ptr의 소유권을 전달하려면 unique_ptr을 값으로 받으면 된다. 후자의 경우 이동 의미론이 필요하다.
- 소유권 전달과 공유가 전혀 없다면 비 const 대상에 대한 레퍼런스나 const에 대한 레퍼런스로 매개변수를 정의해야 한다. 또는 매개변수에 nullptr을 가지는 것이 적합하다면 일반 포인터로 정의한다.
7.5.5 함수에서 리턴하기
- 표준 스마트 포인터인 shared_ptr, unique_ptr, weak_ptr은 함수에서 값으로 리턴하는 것을 쉽고 효율적으로 처리한다.
- 이는 리턴값 최적화(RVO)와 이름 있는 리턴값 최적화(NRVO), 이동 의미론 덕분이다.
7.5.6 enable_shared_from_this
- std::enable_shared_from_this를 상속해서 클래스를 만들면 객체에 대해 호출한 메서드가 자신에게 shared_ptr이나 weak_ptr을 안전하게 리턴할 수 있다.
- 이 클래스 없이 올바른 shared_ptr이나 weak_ptr을 리턴하는 방법은 weak_ptr을 이 클래스의 멤버로 추가한 뒤 이를 복제해서 리턴하거나, 이를 이용하여 생성한 shared_ptr을 리턴하는 것이다.
- enable_shared_from_this 클래스를 상속하면 다음과 같은 두 메서드가 추가된다.
- shared_from_this() : 객체의 소유권을 공유하는 shared_ptr을 리턴한다. 객체의 포인터가 이미 shared_ptr에 저장된 경우에만 객체에 shared_from_this() 을 사용할 수 있다. 다른 경우에는 bad_weak_ptr 익셉션이 발생한다.
- weak_from_this() : 객체의 소유권을 추적하는 weak_ptr을 리턴한다.
- shared_from_this()와 weak_from_this()는 모두 public 메서드다.
7.5.7 현재는 폐기된 auto_ptr
- C++11 이전에는 표준 라이브러리에서 스마트 포인터를 간단히 구현한 auto_ptr을 제공했는데, 아쉽게도 몇 가지 심각한 단점이 있다.
- vector와 같은 표준 라이브러리 컨테이너 안에서는 제대로 작동하지 않는다.
- C++11과 C++14부터는 auto_ptr을 공식적으로 폐기했고, C++17부터 완전히 삭제되면서 그 빈자리를 unique_ptr과 shared_ptr이 대체했다.
- 기존에 제공되던 스마트 포인터인 auto_ptr을 사용하지 말고 unique_ptr이나 shared_ptr을 사용한다.
7.6 정리
- 동적 메모리에 관련된 문제를 방지하기 위해 다음 두 가지 사항을 명심한다.
- 첫째, 포인터의 내부 작동 방식을 이해한다.
- 둘째, 소유권과 관련된 부분을 일반 포인터로 구현하지 말고, C스타일 구문과 함수도 사용하지 않는다. 그 대신 string 클래스나 vector 컨테이너나 스마트 포인터처럼 메모리를 자동으로 관리하는 객체를 사용한다.
'Programming II > C++' 카테고리의 다른 글
[전문가를 위한 C++] CHAPTER9 클래스와 객체 완전 정복 (0) | 2025.04.10 |
---|---|
[전문가를 위한 C++] CHAPTER8 클래스와 객체 이해 (0) | 2025.04.09 |
[전문가를 위한 C++] CHAPTER6 재사용을 고려한 설계 (0) | 2025.04.05 |
[전문가를 위한 C++] CHAPTER5 객체지향 설계 (1) | 2025.04.02 |
[전문가를 위한 C++] CHAPTER4 전문가답게 C++프로그램 설계하기 (0) | 2025.04.02 |