1. 스택과 힙
C++ 애플리케이션에서 사용하는 메모리는 스택과 힙으로 나뉜다.
스택
스택은 제일 위에 놓인 프로그램의 현재 스코프를 표현하며, 주로 현재 실행 중인 함수를 가리킨다.
현재 실행 중인 함수에 선언된 변수는 모두 최상단 스택 프레임의 메모리 공간에 담겨있다.
현재 foo()라는 함수가 실행된 상태에서 bar()라는 다른 함수를 호출하면
최상단에 bar()라는 함수가 올라온다. 이를 스택 프레임이 올라온다고 한다.
foo()에서 bar()로 전달되는 매개변수는 모두 foo()의 스택 프레임에서 bar()의 스택 프레임으로 복제된다.
다음은 두 개의 정숫값이 선언된 foo() 함수가 실행되는 동안의 스택 프레임 상태를 보여준다.
스택 프레임은 각각의 함수마다 독립적인 메모리 공간을 제공한다.
foo() 함수에 대한 스택 프레임에 변수 한 개가 선언돼 있을 때
다른 함수를 호출하더라도 특별히 수정하지 않는 한 그 변수는 그대로 유지된다.
함수의 실행이 끝나면 해당 스택 프레임은 삭제되기 때문에 변수가 메모리 공간을 차지하지 않는다.
프로그래머가 직접 할당 해제할 필요 없이 자동으로 처리된다.
힙
힙이란 현재 함수 또는 스택 프레임과는 완전히 독립적인 메모리 공간이다.
함수가 끝난 후에도 그 안에서 사용하던 변수를 계속 유지하고 싶다면 힙에 저장한다.
프로그램에서 원하는 시점에 언제든지 새로 추가하거나 수정할 수 있다.
힙에 할당된 메모리 공간은 직접 할당 해제(삭제)해야 한다.
힙은 스마트 포인터를 사용하지 않는 한 자동으로 할당 해제되지 않는다.
2. 포인터 사용법
메모리 공간을 적당히 할당하면 어떠한 값이라도 힙에 저장할 수 있다.
정숫값을 힙에 저장하려면 정수 타입에 맞는 메모리 공간을 할당해야 하는데 이때 포인터를 선언해야 한다.
int* myIntegerPointer;
int 타입 뒤에 붙은 별표(*)는 이 변수가 정수 타입에 대한 메모리 공간을 가리킨다는 것을 의미한다.
이때 포인터는 동적으로 할당된 힙 메모리를 가리키는 화살표와 같다.
현재 아직 값을 할당하지 않았기 때문에 포인터가 구체적으로 가리키는 대상이 없다.
이를 초기화되지 않은 변수라 부른다.
포인터 변수는 반드시 초기화해야 한다.
포인터를 초기화하지 않고 사용하면 거의 대부분 프로그램이 과부화된다.
그래서 포인터 변수는 항상 선언하자마자 바로 초기화한다.
포인터 변수에 메모리를 당장 할당하고 싶지 않다면 널 포인터(nullptr)로 초기화한다.
int* myIntegerPointer = nullptr;
널 포인터란 정상적인 포인터라면 가지지 않을 특수한 값이며, 불(bool) 표현식에서는 false로 취급한다.
if(!myIntegerPointer) { ... }
포인터 변수에 메모리를 동적으로 할당할 때는 new 연산자를 사용한다.
myIntegerPointer = new int;
이렇게 하면 정숫값 하나에 대한 메모리 주소를 가리킨다.
이 포인터가 가리키는 값에 접근하려면 포인터를 역참조(참조 해제, 참조 풀기)해야 한다.
역참조란 포인터가 힙에 있는 실젯값을 가리키는 화살표를 따라간다는 뜻이다.
입에 새로 할당한 공간에 정숫값을 넣으려면 다음과 같이 작성한다.
*myIntegerPointer = 8;
이 문장은 myIntegerPointer = 8;과 전혀 다르다.
위에서 변경하는 값은 포인터(메모리 주소)가 아니라 이 포인터가 가리키는 메모리에 있는 값이다.
만약 변수 앞에 나온 *가 없으면 메모리 주소가 8인 지점을 가리키는데,
거기에 의미없는 값이 담겨 있어 실행하면 프로그램이 종료될 가능성이 높다.
동적으로 할당된 메모리를 다 쓰고 나면 delete 연산자로 그 공간을 해제해야 한다.
메모리를 해제한 포인터를 다시 사용하지 않도록 다음과 같이 곧바로 포인터 변수의 값을 nullptr로 초기화하는 것이 좋다.
delete myIntegerPointer;
myIntegerPointer = nullptr;
포인터는 힙뿐만 아니라 스택과 같은 다른 종류의 메모리를 가리킬 수도 있다.
원하는 변수의 포인터값을 알고 싶다면 주소 참조 연산자인 &를 사용한다.
int i = 8;
int* myIntegerPointer = &i; // 8이란 값을 가진 변수 i의 주소를 가리키는 포인터
C++은 구조체의 포인터를 다루는 부분을 조금 다르게 표현한다.
* 연산자로 역참조해서 구조체 자체(시작 지점)에 접근한 뒤 필드에 접근할 때는 . 연산자로 표기한다.
Employee* anEmployee = getEmployee();
cout << (*anEmployee).salary << endl;
좀 더 간결하게 표현하고 싶다면 ->(화살표) 연산자로 구조체를 역참조해서 필드에 접근하는 작업을 표현한다.
다음 코드는 위의 코드와 동일한 결과이다.
Employee* anEmployee = getEmployee();
cout << anEmployee->salary << endl;
포인터를 다룰 때 논리 연산을 이용해 잘못된 포인터에 접근하지 않게 할 수 있다.
bool isValidSalary = (anEmployee && anEmployee->salary > 0);
풀어서 쓰면 다음과 같이 작성할 수 있다
bool isValidSalary = (anEmployee != nullptr && anEmployee->salary > 0);
anEmployee의 포인터값이 올바를 때만 역참조해서 급여 정보를 가져온다.
3. 동적으로 배열 할당하기
배열을 동적으로 할당할 때도 힙을 활용한다. 이때 new[] 연산자를 사용한다.
int arraySize = 8;
int*myVariableSizedArray = new int[arraySize];
정수 타입 원소에 대해 arraySize 변수로 지정한 개수만큼 메모리가 할당된다.
다음 그림은 이 코드를 실행한 직후의 스택과 힙의 상태를 보여준다.
포인터 변수는 여전히 스택 안에 있지만 동적으로 생성된 배열은 힙에 있다.
이렇게 메모리를 할당한 뒤에는 myVariableSizedArray를 일반 스택 기반 배열처럼 다룰 수 있다.
myVariableSizeArray[3] = 2;
배열을 이용한 작업이 끝나면 다른 변수가 힙의 메모리 공간을 쓸 수 있도록 배열을 힙에서 제거햐야한다.
C++에서 이 작업은 delete[] 연산자로 처리한다.
deletep[] myVariableSizeArray;
myVariableSizedArray = nullptr;
여기서 delete 뒤에 붙은 대괄호는 배열을 삭제한다는 것을 의미한다.
4. 널 포인터 상수
C++11 이전에는 NULL이란 상수로 널 포인터를 표현했다.
NULL은 실제로 상수 0과 같아서 문제가 발생할 여지가 있다.
void func(char* str) { cout << "char* version" << endl; }
void func(int i) { cout << "int version" << endl; }
int main() {
func(NULL);
return 0;
}
main() 함수를 보면 func()를 호출할 때 매개변수로 널 포인터 상수인 NULL을 지정했다.
여기서는 char* 인수를 받는 버전의 func()를 호출하려고 널 포인터를 인수로 지정했지만,
NULL은 포인터가 아니라 정수 0에 해당하기 때문에 정수 인수를 받는 버전의 func()가 호출된다.
이럴 때는 정식 널 포인터 상수인 nullptr를 사용한다.
다음과 같이 호출하면 char* 버전의 func()가 호출된다.
func(nullptr);
5. 스마트 포인터
기존 C 스타일의 포인터 대신 스마트 포인터를 사용하면 메모리와 관련하여 흔히 발생하는 문제를 방지할 수 있다.
스마트 포인터로 지정한 객체가 스코프를 벗어나면 메모리가 자동으로 해제된다.
C++에서 중요한 스마트 포인터 타입은 두가지가 있다.
둘 다 <memory> 헤더 파일에 정의돼 있으며 std 네임스페이스에 속해 있다.
- std::unique_ptr
- std::shared_ptr
Unique_ptr
unique_ptr은 포인터로 가리키는 대상이 스코프를 벗어나거나 삭제될 때 할당된 메모리나 리소스도 자동으로 삭제된다.
그러나 unique_ptr이 가리키는 객체를 일반 포인터로는 가리킬 수 없다.
unique_ptr은 return 문이 실행되거나 예외가 발생하더라도 항상 할당된 메모리나 리소스를 해제할 수 있는 장점이 있다.
함수에 return 문을 여러 개 작성하더라도 각각에 대해 리소스를 해제하는 코드를 작성할 필요가 없다.
unique_ptr을 생성할 때는 반드시 std::make_unique<>()를 사용해야 한다.
auto anEmployee = make_unique<Employee>();
주목할 점은 delete가 자동으로 호출되기 때문에 delete를 호출하는 문장을 따로 적을 필요가 없다.
auto 키워드는 컴파일러가 변수의 타입을 추론하기 때문에 타입을 구체적으로 지정할 필요가 없다.
unique_ptr은 제네릭 스마트 포인터라서 어떠한 종류의 메모리도 가리킬 수 있다.
make_unique()는 C++14부터 추가됐다.
C++14를 지원하지 않는 컴파일러를 사용한다면 다음과 같은 방법으로 unique_ptr을 만든다
unique_ptr<Employee> anEmployee(new Employee);
스마트 포인터로 지정한 anEmployee의 사용법은 일반 포인터와 같다.
if (anEmployee) {
cout << "Salary: " << anEmployee->salary << endl;
}
unique_ptr은 C 스타일 배열을 저장하는 데도 활용할 수 있다.
다음 예는 열 개의 Employee 인스턴스로 구성된 배열을 생성하여 이를 unique_ptr에 저장하고,
배열에 담긴 원소를 접근하는 방법을 보여준다.
auto employees = make_unique<Employee[]>(10);
cout << "salary " << employees[0].salary << endl;
shared_ptr
shared_ptr은 std::make_shared<>()로 생성한다. 방법은 make_unique<>()와 비슷하다.
auto anEmployee = make_shared<Employee>();
if (anEmplotee) {
cout << "salary: " << anEmployee->salary << endl;
}
C++17부터 shared_ptr에 배열도 저장할 수 있다.
이전 버전에서는 이 기능을 지원하지 않는다.
배열을 저장하는 shared_ptr을 생성할 때는 make_shared<>()를 사용할 수 없고, 다음과 같이 작성해야 한다.
shared_ptr<Employee[]> employees(new Employee[10]);
cout << "Salary: " << employees[0].salary << endl;
'💻프로그래밍 내용 정리 > C++17' 카테고리의 다른 글
[C++ 1.2.4] 레퍼런스, Const 레퍼런스 (0) | 2022.08.16 |
---|---|
[C++ 1.2.3] Const의 다양한 용도 (0) | 2022.08.16 |
[C++ 1.2.1] C++의 스트링 (0) | 2022.08.16 |
[C++ 1.1.14] 반복문(while 문, do/while, for, 범위 기반 for 문) (0) | 2022.08.15 |
[C++ 1.1.13] 구조적 바인딩(auto) (0) | 2022.08.15 |