프로그래밍 공부
작성일
2024. 1. 9. 13:41
작성자
WDmil
728x90

1.2.1 포인터와 동적 메모리

동적 메모리는 컴파일 타임에 크기를 정할 수 없는 데이터를 이용할 수 있게 해준다.

 

거의 모든 프로그램은 동적 메모리를 어떤 형태로든 이용하게 된다.

 

스택과 힙

C++  애플리케이션이 사용하는 메모리는 스택(stack)힙(heap) 두가지로 나누어진다.

 

스택 메모리는 쌓여있는 카드에 비유할 수 있다.

 

가장 위에 놓인 카드는 프로그램의 상태를 나타내는데, 보통 현재 실행중인 함수에 관한것 이다.

 

현재 실행 중인 함수의 로컬 변수는 모두 가장 위에 놓인 카드, 즉 제일 위 스택에 위치한다.

 

만약 현재 실행 중인 함수 foo()가 또 다른 함수 bar()를 호출한다면 새로운 스택 프레임 이 생성되고, bar() 함수를 호출할 때 넘겨진 파라미터가 foo()의 스택 프레임에서 복제되어 새로 생성된 bar() 함수를 호출할 때 넘겨진 파라미터가 foo() 의 스택 프레임에서 복제되어 새로 생성된 bar()의 스택 프래임에 옮겨진다.

 

main() 함수가 두개의 int타입 파라미터를 인자로 갖는 foo() 함수를 호출했을 때의 상황을 나타낸다.


스택 프레임

스택 프레임은 각 함수 간 메모리 공간을 격리시키는 중요한 역할을 한다.

 

예를 들어 foo()함수의 로컬 변수는 bar() 함수가 호출되는 동안 바뀌지 않는다.

(단, 명시적으로 bar()함수에 해당 변수의 메모리 위치를 넘겨주는 경우는 예외이다.)

 

그리고 bar()함수의 실행이 끝나고 함수가 리턴되면, bar()함수의 로컬 변수들은 bar()의 스택 프레임과 함께 사라지기 때문에, 더이상 메모리를 차지하지 않는다.

 

즉, 스택에 할당된 메모리를 프로그래머가 직접 해제할 필요가 없다. 메모리에서 자동으로 해제된다.


힙 메모리는 현재 실행중인 스택 프레임과는 완전히 독립적인 메모리 영역이다.

 

함수의 호출과 리턴에 관계없이 항상 존재해야 하는 변수라면, 힙에 위치시키면 된다.

 

힙 메모리는 비트 창고에 비유할 수 있다.

 

언제든 필요하다면 창고에 비트믄 보관하거나, 보관된 비트를 수정할 수 있다.

 

힙에 할당된 메모리는 반드시 프로그래머가 직접 해제해야 한다.

 

힙 메모리에 대한 해제는 자동으로 일어나지 않는다.

 

단, 스마트 포인터를 사용할 경우에는 더 이상 사용하는 곳이 없을 때, 자동으로 해제되도록 할 수 있다.


포인터의 이용

 

힙에 명싲거으로 메모리를 할당하면 무엇이든 저장할 수 있다. 예를 들어 힙에 정수값을 저장하고 싶다면 메모리를 할당해야 한다.

 

이를위해 먼저 포인터를 변수 선언할 필요가 있다.

int* myIntergerPointer;

int타입 키워드 뒤에 있는* 기호는 이 변수가 정수값이 지정된 메모리 영역을 가리키기 위한 변수임을 알려준다.

 

포인터를 동적으로 할당된 힙 메모리를 가리키는 화살표로 생각해도 된다.

 

위와 같이 선언된 상태에서는 아직 아무것도 가리키는 것이 없다.

 

즉, 초기화되지 않은 변수다.

 

초기화되지 않은 변수는 임의의 값을 실수로라도 참조할 수 있기 때문에 최대한 빨리 값을 지정해주는것 이 바람직하다.

 

특히 초기화되지 않은 포인터 변수라면 임의 메모리 영역을 가리키고 있을 것이기 때문에 더 위험하다.

 

초기화되지 않은 포인터 변수를 사용하면, 프로그램이 크래시될 가능성이 높다.

 

이때문에 포인터 변수는 선언하자마자 초기화 해주어야 한다.

 

당장 메모리를 할당할 수 없다면 nullptr로 초기화 해주자.

int* myIntergaerPointer = nullptr;

메모리를 할당할 때는 new 연산자를 사용한다.

myIntegerPointer = new int;

여기서 포인터는 정수 변수 단 하나가 위치한 주소를 가리킨다. 변수값에 접근하기 위해서는 포인터를 역참조(dereference) 해야한다.

 

역참조는 힙에 위치한 값을 가리키는 화살표로 생각하면 이해하기 쉽다.

 

힙 메모리에 새로운 값을 집어넣을 때는 아래와 같이 한다.

*myIntergerPointer = 8;

이때 myIntergerPointer에 값 8을 대입하는 것이 아니라는 점에 주의해야 한다.

 

포인터 자체는 바뀌지 않고 그 포인터가 가리키는 메모리의 값이 바뀌는 것이다.

 

만약 포인터 자체에 값을 대입해버리면 주솟값 8에 위치한 임의의 값을 가리키게 되어 그 위치의 메모리 값을 조작하게 될 경우 프로그램이 오작동할 수 있다.

 

동적으로 할당된 메모리의 사용이 끝났다면 delete 연산자를 이용하여 할당을 해제(메모리 반환) 해야 한다.

 

할당된 해제된 메모리에 접근하는 것을 막기 위해 해제한 직후 nullptr로 초기화 하는 것이 바람직하다.

delete myIntergetPointer;
myIntergerPointer = nullptr;

포인터는 역참조(값 이용) 되기 전에 반드시 메모리가 할당되어 초기화되어야 한다.

null이나 초기화되지 않은 주솟값이 들어 있으면 그 퐁니터를 이용할 때 프로그램이 오작동 하거나 비정상 종료된다.

 

포인터가 항상 힙 메모리만 가리키는것 이 아니다. 스택을 가리킬 수도 있고, 심지어 또 다른 포인터를 가리킬 수도 있다.

 

변수로부터 포인터를 얻을 때는 주소 참조 연산자&를 이용한다.

int i = 8;
int* myIntergerPointer = &i;

포인터로 구조체를 참조할 때는 특이 문법을 사용한다.

 

기술적으로는 값에 접근하기 위해 추가적으로 역참조 연산자 *를 포인터에 붙이고, 이렇게 역참조된 변수에 .을 붙여서 구조체 내 각 필드를 선택한다.

 

Employee* anEmployee = getEmployee();
cout << (*anEmployee).salary << endl;

 

구조체 역참조 연산자 -> 를 이용하면 위와같이 복잡하게 사용하지 않고도 역참조와 필드접근을 한번에 할 수 있다.

Employee* anEmployee = getEmployee();
cout << anEmployee->salary << endl;

 

보통 함수를 호출하면서 파라미터 전달을 위해 인자로 이용할 때 해당 변수의 주소가 아니라 그 값을 넘기게 된다.

 

이러한 방법을 값에 의한 전달(passing by value) 라고 한다. 예를들어 어떤 함수가 정수값을 파라미터로 받고, 함수 호출 시 정수 변수를 인자로 넘겼다면 해당 변수의 값을 복사하여 함수 스택 프레임 안의 파라미터 영역에 위치시킨다.

 

그런데 변수대신 변수의 주소를 가리키는 포인터 를 인자로 전달할 수 있다.

 

C에서는 함수가 변수값을 바꿀 수 있도록 하기위해 포인터전달 방식을 사용한다.

 

포인터를 인자로 전달하면, 스텍프레임 안에 복사되어 저장되는 것이  변수의 값이 아닌 변수의 주소가 되기 때문에, 그 주소를 역참조함으로써 스택 프레임 밖에 있는 변수의 값을 변경하는 것이 가능해진다.

 

이러한 파라미터 전달을 참조에 의한 전달(passing by reference) 라고 한다.

 

C++에서는 참조형 변수 라는 더 좋은 방식을 지원하기 때문에, 포인터를 통한 우회적인 방법을 사용하지 않아도 된다.


값에 의한 전달 (passing by value)

  • 함수에 전달되는 변수의 값이 복사되어 매개변수에 전달됨.
  • 함수 내에서 매개변수의 값을 변경해도 호출자의 변수에는 영향을 주지 않음.

참조에 의한 전달(passing by reference)

  • 함수에 전달되는 변수의 메모리주소(참조)가 매개변수에 전달됨.
  • 함수내에서 매개변수를 통해 직접 메모리 위치를 참조함으로 값을 변경하면 호출자의 변수도 변경됨.

참조형 변수(Reference Types)

  • 참조형 변수는 기존 변수의 별칭(alias)이며, 동일한 메모리 주소를 가리킨다.
  • 선언시 반드시 초기화 되어야한다.
  • 값에 의한 복사가 아닌, 원본 변수와 동일한 메모리를 공유함으로 한쪽에서의 변경이 다른쪽에 반영된다.

동적 할당 배열

 

힙은 동적으로 배열을 할당하는데 쓰일 수 있다.

 

new[] 연산자를 이용하여 배열을 위한 메모리 공간을 할당할 수 있다.

int arraySize = 8;
int* myVariableSizedArray = new int[arraSize];

int 변수를 arraySize개 만큼 저장할 수 있는 힙 메모리를 확보하여 그 주솟값을 포인터 변수에 대입해준다.

 

이렇게 할당된 메모리는 스택 배열처럼 이용할 수 있다.

myVariableSizedArray[3] = 2;

동적으로 할당된 배열을 더는 사용하지 않는다면 명시적으로 힙 메모리에서 해제해야 한다.

 

C++에서는 다음과 같이 delete[] 연산자를 이용해서 메모리를 해제할 수 있다.

delete [] myVariableSizeArray;

C에서 가져온 malloc()과 free()의 사용은 가능한 지양해야 한다. 대신 new와 delete 또는, new[]와 delete[]를 사용한다.

 

메모리릭(memory leak)을 막기위해 new와 delete, new[]와 delete[]는 각각 짝을 맞춰서 이용되어야 한다.

 

배열을 대상으로 new[]를 했다면, 반드시 배열에 대한 delete[]를 수행해야 메모리 릭을 막을 수 있다.


null 포인터 상수

 

C++이전에는 상수값 0이 숫자 0이기도 하고 null 포인터 이기도 했다.

void func(char* str) { cout << "char* version" << endl; }
void func(int i) { cout << "int version" << endl; }
int maiN()
{
	func(NULL);
    return 0;
}

여기에서 화인했을 때, func()를 호출할 경우, 인자로 NULL을 넘기고 있다. 이 값은 숫자 0이 아닌 null포인터를 의도했을 가능성이 높다.

 

즉, func(int i)대신 func(char* str)을 호출하려 했을 것이다.

 

그러나, 컴파일러는 NULL은 포인터가 아니라 정수 0과 같기때문에, func(int i)를 호출하게 된다.

 

이러한 문제는 nullptr이라는 새로운 상수를 사용하여 대응할 수 있다.

func(nullptr);

위와같이 호출하면, char*버전의 func()함수가 호출된다.


스마트 포인터

 

이러한 메모리문제들을 피하기 위해서 기존 C언어 스타일의 일반 포인터 대신, 스마트 포인터를 사용해야 한다.

 

스마트 포인터는 객체에 유효한 스코프가 더이상 없을 때 (함수의 리턴 등으로 스코프를 벗어날 때) 자동으로 메모리를 해제한다.

 

C++의 스마트포인터는 세 종류가 있는데 각각 다음과 같다.

std::unique_ptr

std::shared_ptr

std::weak_ptr

로 모두 <memory>헤더파일에 존재한다.


std::unique_ptr

 

스코프를 벗어날 때 자동으로, 또는 명시적으로 delete가 수행될때 메모리를 해제한다.

 

포인팅하고 있는 객체에 대해 단독으로 오너십을 가진다.

 

unique_ptr를 사용하면 예외상황( 익셉션이나 갑작스런 리턴) 이 발생했을 때 메모리해제를 단순하게 할 수 있다.

 

unique_ptr를 만들 때 에는 std::make_unique<>()를 이용한다.

Employee* anEmployee = new Employee;

auto anEmplyee = std::make_unique<Employee>();

위와같이 객체를 다르게 선언해줄 수 있다.

 

이러한 make_unique()는 C++14부터 사용할 수 있다. 만약 컴파일러가 C++14를 지원하지 않는다면 다음과같이 사용해야 한다.

std::unique_ptr<Employee> anEmployee(new Employee);

 

이러한 변수는 스마트 포인터가 된다. 사용법은 일반 포인터와 동일하다.

 

unique_ptr은 범용 스마트 포인터로, 어떤 메모리이든 참조할 수 있다.

 

템플릿으로 만들어져있기 때문이다.


std::shared_ptr

 

shared_ptr은 데이터에 대한 오너십이 여기 저기 분산될 수 있게 해준다.

 

shared_ptr변수가 다른 변수에 대입될 때 마다 레퍼런스 카운트가 증가되어 데이터의 오너가 하나 더 늘었다는것 을 표시한다.

 

shared_ptr 변수가 모든 스코프를 벗어날 때 에는 레퍼런스 카운터가 0이되며, 이는 오너가 더이상 없다는 뜻 임으로 포인터에 의해 참조되고 있는 객체의 메모리가 해제된다.

 

unique_ptr과 다르게, shared_ptr은 배열을 지원하지 않는다. shared_ptr를 생성할 때 에는 std;:make_shared<>()를 사용한다.

 


std::weak_ptr

 

weak_ptr은 shared_ptr에 대입된 객체를 참조하되, 레퍼런스 카운터에 영향을 주고 싶지 않을 때 사용한다.

 


기존의 일반 포인터 변수는, 오너십이 필요 없거나 오너십이 공유되지 않는 경우에만 사용한다.

 

기본적으로 unique_ptr을 사용하고, 오너십이 공유되어야 하는 경우에는, shared_ptr를 사용한다.

 

auto_ptr은 더이상 C++표준에서 사용하지 않는다.

728x90