C++ & C#

[C++] Pointer에 관하여

송만덕 2021. 10. 26. 00:39

Pointer란?

 

어떠한 값을 저장하는 게 아닌 메모리 주소를 저장하는 변수.

포인터 변수는 일반 변수처럼 선언되며, 자료형과 변수 이름 사이에 별표(*)가 붙는다.

1
2
int* iPtr;
float* fPtr;
cs

 

포인터는 선언 시 초기화되지 않으며, 쓰레기 값을 지닌다.

 

또 포인터를 사용하는 가장 흔한 경우는 동적으로 메모리를 할당하기 위함이며,

C언어에 존재하는 배열 또한 포인터로 구현된다 (데이터형의 크기에 따른 연속적인 주소할당).


Pointer에 값 할당

 

포인터는 메모리 주소를 저장하기 때문에 포인터에 할당할 값은 주소여야 한다.

어떠한 변수의 주소를 얻기 위해선 주소 연산자 (&)를 사용하며 아래와 같은 방식으로 할당하게 된다. 

1
2
3
4
int n = 5;
int* iPtr;
 
iPtr = &n;
cs

포인터변수는 할당할 변수의 주소값을 갖게되어 포인터변수는 할당변수를 가리킨다고 할 수 있다.

 

실제로 iPtr을 프린트 해보면 주소값이 나타나게 되며, n이 지닌 값을 알기 위해서는

1
2
3
4
5
int n = 5;
int* iPtr;
 
iPtr = &n;
cout << *iPtr;
cs

위와 같이 역참조 연산자 (*)를 통해 역참조한 변수를 사용하면 된다.

결국 " iPtr = 할당한 변수의 주소값 // *iptr = 할당한 변수의 value "가 된다.

 

포인터는 null값 또한 저장할 수 있는데, 0으로 초기화 하거나 null을 직접 할당하면 되며

C++11부터는 nullptr이라는 키워드를 사용할 수 있다.

 

포인터가 null이면 Bool값은 false, 그렇지 않다면 true가 된다.


포인터의 크기

 

포인터의 크기는 실행 파일이 컴파일된 아키텍처에 따라 달라진다.

※ 32비트 실행파일 = 32비트 메모리 주소를 사용 = 32비트 (4바이트)
※ 64비트 실행파일 = 64비트 메모리 주소를 사용 = 64비트 (8바이트)

이렇듯 아키텍처에 따라 포인터의 크기는 다르지만, 할당된 포인터의 크기는 항상 고정이다.

이유는 포인터는 메모리 주소값이며, 메모리 주소에 접근하기 위해선 위의 비트만 필요하기 때문이다.


배열

 

배열 변수는 포인터와 같이 배열의 첫 번째 요소의 주소를 가지고 있다.

하지만 배열 변수의 TYPE은 type[]이고, 배열에 대한 포인터의 타입은 type* 이다.

 

또 포인터를 통해 배열의 모든 요소에 접근 가능하지만, 배열 타입에서 파생된 정보 (Ex. 배열 길이)는 포인터에서 접근할 수 없다.

ex) sizeof() 연산자의 경우 배열에선 전체 크기를 반환하지만, 포인터에서 사용하면 메모리 주소의 크기(바이트) 반환.

 

포인터는 배열을 역참조하여 첫 번째 요소의 값을 얻을 수도 있지만, 실제로 역참조 하지는 않으며

배열은 type의 포인터로 암시적으로 변환되고, 포인터를 역참조하여 포인터가 보유하고 있는 메모리 주소의 값을 얻을 수 있게 되는 것이다. (배열의 첫 번째 요소의 값)

 

또한 매개 변수로 사용될 시 배열은 복제되지 않고 실제 배열을 가리킨다.


동적 할당

 

C++에서는 세가지 메모리 할당을 지원한다.

1. 정적 메모리 할당 : 정적 변수와 전역 변수에 대해 발생하며, 변수에 대한 메모리는 프로그램이 실행될 때 한 번 할당되어 프로그램 수명 내내 지속된다.
2. 자동 메모리 할당 : 함수 매개 변수와 지역 변수에 대해 발생하며, 변수에 대한 메모리는 관련 블록을 입력할 때 할당되고, 블록을 종료할 때 필요에 따라 해제된다.
3. 동적 메모리 할당 : 프로그램 실행 중에 필요한 메모리를 운영체제에 요청, 사용자가 원하는 시점에 해제 가능.
1
2
3
4
5
6
7
int *ptr = new int;
*ptr = 5;
 
int *ptr2 = new int{5};
 
delete ptr;
delete ptr2;
cs

 

위의 예시와 같이 포인터와 new type을 사용하여 배열을 동적으로 할당할 수 있다.

또 동적 할당을 사용한 경우 delete를 사용하여 할당을 꼭 해제하여야 메모리 누수를 막을 수 있다.

 

또 포인터를 사용하면 고정 배열이 아닌 동적으로 할당하여 변수를 만들 수 있다.

 

동적 할당 배열의 장점은 고정 배열의 경우 필요한 크기를 알 수 없는 상황에서는 배열의 크기를 크게 생성하여 사용하기 때문에 메모리가 많이 낭비되지만, 동적 할당의 경우 원하는 만큼만 사용하며 할당을 해제하여 메모리를 관리할 수 있게 된다.

1
2
3
4
5
int* arr = new int[len];
 
int* arr2 = new int[] {1,2,3,4,5};

int n=5;
int* arr3 = new int[n]{1,2,3,4,5};
cs

 

위의 예시와 같이 포인터와 new type[]을 사용하여 배열을 동적으로 할당할 수 있다.

꼭 배열의 길이를 꼭 명시하여 선언해야 한다.

 

또 1차원이 아닌 2차원 이상의 배열 또한 가능하다.

1
2
3
4
5
6
7
8
int num = 5;
int **arr = new int*[num];
for (int i=0;i<num;i++)
    arr[i] = new int[num];
 
for(int i=0;i<num;i++)
    delete[] arr[i];
delete[] arr;
cs

 

다차원 배열의 경우 선언에서 포인터를 두 개를 사용하게 되며 type에서도 포인터를 사용하여 num개 만큼의 주소를 가르키게 된다.

또 반복문을 통해서 num개 만큼의 배열을 할당하여서 총 num*num 배열이 할당되는 방식이다.

해제의 경우도 독특한데, 내부의 배열을 모두 해제한 후 외부 배열을 해제하여야 한다.

이유는 내부를 해제하지 않고 외부를 해제하게 되면 내부에 할당된 메모리가 누수되기 때문이다.


동적 할당의 메모리

동적으로 할당된 메모리는 일반 변수와는 다른 메모리 영역에 할당되게 된다.

 

프로그램이 사용하는 메모리는 일반적으로 세그먼트라고 하는 몇 가지 영역으로 나뉜다.

코드 세그먼트 : 컴파일된 프로그램이 저장되는 영역, 일반적으로 read-only 속성이다.
데이터 세그먼트 : 전역 변수 및 정적 변수가 저장되는 영역
힙 세그먼트 : 동적으로 할당된 변수가 할당되는 영역
스택 세그먼트 : 함수 매개 변수, 지역 변수 및 기타 함수 관련 정보가 저장되는 영역

일반 변수는 '스택'이라는 메모리 영역에 할당된다.

스택은 메인 함수부터 현재 실행 지점까지의 모든 활성 함수를 추적하고, 모든 함수 매개 변수와 지역 변수의 할당을 처리한다.

응용프로그램이 시작되면 메인함수가 운영체제에 의해 푸시되고, 다음 함수 호출이 발생할 때마다 푸시와 팝이 발생하는 방식이다.

콜 스택에 넣고 빼는 데이터 자체를 스택 프레임이라고 하며 스택 프레임은 하나의 함수 호출과 관련된 모든 데이터를 추적하고

스택 포인터라고 하는 레지스터는 현재 호출 스택의 최상위 위치를 가리킨다.

스택 프레임은 아래와 같이 구성되어 있다.

*함수가 종료되면 복귀할 주소
*함수의 모든 매개 변수
*지역 변수
*함수가 반환할 때 복원해야 하는 수정된 레지스터의 복사본

일반적으로 비주얼스튜디오는 스택 크기를 1MB로 기본 설정하며, 이 크기를 초과하면 스택 오버플로가 발생하고 운영체제에서 프로그램을 종료하게 된다.

 

스택 세그먼트의 작동 방식과 스택오버플로에 대한 내용은 따로 더 자세히 다루겠다.


하지만 포인터나 동적 메모리 할당을 사용하게 되면 스택이 아닌 '힙'이라는 메모리 영역에 할당되게 된다.

힙 영역은 운영체제에서 관리하는 훨씬 더 큰 메모리 풀이기 때문에 스택보다도 크기가 크며, 유연하고 효율적이게 사용할 수 있다.

 

하지만 힙에 메모리를 할당하는 것은 비교적 느리고, 또한 동적으로 할당된 메모리는 포인터를 통해 접근하기 때문에

포인터를 역참조하는 것은 변수에 직접 접근하는 것 보다 느리다.

또한 사용자가 할당한 메모리를 해제하지 않는다면 메모리 누수가 생기기 때문에 조심해야 하는 부분이다.