Program Languege/more

포인터, 배열, 동적할당

Frozen0113 2013. 3. 31. 17:25

포인터, 배열, 동적할당

 

다시 한번 포인터와 배열에 대해 먼저 이야기를 하겠습니다.

 

배열의 구조를 이해하기 쉽게 그려보도록 하겠습니다. 1차원 배열은 간단하니까 2차원으로 설명을 하겠습니다.

 

 

 int a[4][6] = {}

               

 a[0][]

 

 a[0][0]

a[0][1]

a[0][2]

a[0][3]

a[0][4]

a[0][5]

0XF7EC

→ 

0

0

0

0

0

0

 

 

 0XF7EC

0XF7F0

0XF7F4

0XF7F8

0XF7FC

0XF800

 a[1][]

 

 a[1][0]

a[1][1]

a[1][2]

a[1][3]

a[1][4]

a[1][5]

0XF804

→ 

0

0

0

0

0

0

 

 

0XF804

0XF808

0XF80C

0XF810

0XF814

0XF818

 a[2][]

 

a[2][0]

a[2][1]

a[2][2]

a[2][3]

a[2][4]

a[2][5]

0XF81C

 →

0

0

0

0

0

0

 

 

0XF81C

0XF820

0XF824

0XF828

0XF82C

0XF830 

 a[3][]

 

a[3][0]

a[3][1]

a[3][2]

a[3][3]

a[3][4]

a[3][5]

0XF834

 →

0

0

0

0

0

0

 

 

0XF834

0XF838

0XF83C

0XF40

0XF844

0XF848

 

이런 구조나

 

 

 a[0][]

             
               
               
 

a[0][0]

a[0][1]

a[0][2]

a[0][3]

a[0][4]

a[0][5]

 

 

0

0

0

0

0

0

 
 

0XF7EC

0XF7F0

0XF7F4

0XF7F8

0XF7FC

0XF800

 
               

 

a[1][]

a[1][0]

a[1][1]

a[1][2]

a[1][3]

a[1][4]

a[1][5]

0

0

0

0

0

0

0XF804

0XF808

0XF80C

0XF810

0XF814

0XF818

 

이런식의 구조라고 생각하고 공부하시는게 좀 더 편하지 않을까 싶습니다.

 

개인적으로 배열을 공부하고 포인터와 연관 지어서 공부를 하면서 이런 구조로 생각하고 이해하다보니 좀 더 생각하기가 쉬웠습니다.

 

저 구조를 기준으로 설명 드리면

 

a[0] 또는 *(a + 0)을 가지고 접근할 수 있는 곳은 a[0][0]부터 a[0][5]번까지 입니다. + 1 이나 - 1 등을 더 더해서 이전이나 그 이후로

접근할 수 있지만 가독성을 저하시키는 접근 방법이라고 생각합니다.(그런 식으로 할꺼라면 차라리 1차원으로 배열을 만들어서 사용하는게 낫습니다.)

 

 

a[0] 은 *(a + 0)으로 표현이 가능하며

 

a[0][1] 은 *(*(a + 0) + 1) 로 표현이 가능합니다.

 

이 과정을 보면 a[0][1] 에서 가장 가까운 인덱스를 포인터 연산으로 표현하면 가장 먼저 계산이되는(가장 안쪽 괄호) 연산과 같은 것을 알 수 있습니다.

 

포인터에 대해 어려워하는 분들이 많은데 알고보면 생각보다 쉽고 간단합니다.

 

a[0][1] -> *(a + 0)[1] -> *(*(a + 0) + 1) 이렇게 변환을 하시면 됩니다.

 

- 저 변환 과정에서는 *(a + 0)[1]이라고 표현했는데 이는 알아보기 쉽게 하기 위해서 입니다. *(a + 0)[1] 과 (*(a + 0))[1]은 결과가 다릅니다. []연산자가 *보다 우선순위가 높기때문에 *(a+ 0)[1]은 [1][0]이고 (*(a + 0))[1]은 [0][1]입니다. 그러므로 (*(a + 0))[1]로 쓰는 것이 의도와 맞는 연산입니다. ( *(a + 0)[1] 처럼 섞어서 쓰는 것도 가능하다는 뜻이 되겠죠? )

 

가장 가까운 인덱스부터 변환하면 됩니다. 간단히 이해가 되었길바랍니다.

 

 

그럼 이제 동적할당으로 넘어가겠습니다.

 

배열을 사용하다보면 미리 정해놓은 크기때문에 활용에 있어서 불편함을 느낀 적이 있을거라고 생각됩니다. 예를 들어 학생을 입력받는 성적표 프로그램이 있다면 배열의 크기만큼 학생을 등록할 수 밖에 없습니다. 이런 정적인 크기에 대한 불편함을 해소하는 방법이 동적할당입니다. 원하는 만큼의 크기를 실행도중 그때그때 할당 할 수 있는 개념입니다. 하지만 이 경우에는 따로 해제도 해주어야합니다. 이 부분때문에 어려움을 느끼는 분들이 많으실거라 생각됩니다.

 

일단 일차원 할당 과정을 설명하면서 설명을 이어가겠습니다.

 

Ex> int *p = new int; 

 

new라는 것이 새로 등장했습니다. 이 new라는 것은 C에서 malloc이라든지 calloc 등 동적할당을 하는 것이 내부적으로 동작하는 키워드입니다. C++에 와서 사용법은 좀 더 간단해졌습니다. 저 문장을 봤을 때 p라는 포인터는 왜 있는가 new 뒤에 int라는 것은 무엇인가? 하는 정도의 의문점을 가지게 되리라 생각됩니다. new 뒤에 붙는 자료형은 해당 자료형의 공간을 할당해주는 것을 의미하고 앞에 포인터 p는 할당된 공간을 가리키는 역할을 합니다.

 

할당 받을 자료형과 동일한 자료형의 포인터이어야하며 저 연산을 보기 쉽게 표현해보자면 다음과 같습니다.

 

 

 

 stack 영역

 

   

   heap 영역

   
 

 int *p

     

 new int

 
 

 0x23AE

 

 

 

쓰레기 값 

 
 

 0xED12

     

 0x23AE

 

 

  

           

 

1)heap영역에 int형 공간을 하나 할당합니다.

2)할당한 공간의 주소를 넘겨줍니다.

3)(stack영역에 미리 할당되어 있는)int형 포인터 p는 heap영역에 할당한 공간의 주소 값을 받아 가리키는 역할을 합니다.

 

동적 할당이라는 것은 포인터에 주소 값을 넣어 가리키게하는 주소 값 대입연산과 같습니다. 포인터 p 자체에 새로운 공간을 할당한다고 이해하는 분들도 꽤 있는데 위를 보며 다시 정리하시길 바랍니다.

 

그냥 할당해서 쓰면 되지 왜 포인터를 사용해야하나 하는 궁금증이 생길 수도 있습니다. 일반적으로 변수를 사용할 때 변수의 이름을 씁니다. 하지만 동적할당을 하는 경우 이름이 없습니다. 그리고 어느 공간에 생길지도 모릅니다. 한마디로 할당받은 공간에 접근 방법이 없다는 말입니다. 이를 위해서 포인터를 해당 영역을 접근하는 수단으로써 사용합니다. 그렇기 때문에 포인터는 해당 공간의 자료형과 동일해야하는 것 입니다.

 

 

배열을 동적할당하는 경우를 표현하겠습니다.

 

Ex> int *p = new int[6];

 

 stack 영역

 

           
 

 int *p

           
 

0xAA00

           
 

0xED23

           
 

 

           
 

 

           
               
 heap 영역

 

           
 

 new int[6]

           
 

 쓰레기 값

쓰레기 값 

쓰레기 값 

쓰레기 값 

쓰레기 값 

쓰레기 값 

 
 

 0xAA00

 0xAA04

 0xAA08

 0xAA0C

 0xAA10

 0xAA14

 
               

영역을 나눠서 그리다보니 이번엔 아래로 좀 애매모호하게 되었지만 잘 알아보시리라 생각합니다. 배열을 할당하는 경우에도 할당한 배열의 첫 시작 주소를 보내주고 포인터 p는 배열의 시작 주소를 가집니다. 각 배열에 접근 방법은 p[1]과 같이 쓰거나 *(p + 1)처럼 접근하여 사용하면 됩니다.

 

위에서 6이라고 상수로 했는데 이를 변수로 대체 가능합니다.

 

Ex> int  a = 6;

      int *p = new int[a];

 

 

 

간단하죠??? 좀 영역을 나누다보니 포인터 p가 가리키는 것을 알아보기 쉽게 표현하지는 못했습니다. 안에 들어 있는 주소 값을 잘 보고 이해하시길 바랍니다.

 

 

그럼 이제 이차원 배열의 동적할당을 표현해보도록 하겠습니다.(이해를 돕기 위해서 위에서 사용한 2차원 배열 형식을 사용하겠습니다.)

 

Ex> int **p = new int*[4];

 

 

 

 stack 영역              
               

int **p

             

 0xCA00

             

 0x23A3

             
               
               

 heap 영역

             
               

 new int*[4]

             

*[0]

             

쓰레기 값

             

0xCA00

             

*[1]

             

쓰레기 값

             

0xCA04

             

*[2]

             

쓰레기 값

             

0xCA08

             

*[3]

             

쓰레기 값

             

0xCA0C

             

 

 

위의 할당과정에서 **의 부분을 잘 생각해보시길 바랍니다. 과정은 동일합니다. 하지만 차원이 늘어나게 되면 할당하는데에 있어서도 과정이 늘어납니다. new int[4][5] 이런 식의 할당은 불가능합니다. 한 차원씩 할당이 가능하죠. 그렇기 때문에 new int*[4] 이렇게 먼저 할당을 해주었습니다. 이 때 *가 들어가는 이유는 맨 위에서 2차원 배열의 구조를 그려드렸을 때와 비교해서 생각해보시면 좀 더 쉽게 이해가 되실거라 생각합니다. 지금 할당받은 공간들은 내부적으로 각각 1차원 배열을 가져야합니다. 그래야 2차원 배열의 구조를 갖게 되겠죠? 1차원 배열을 가져야한다는 말은 위에서 1차원 배열을 동적할당 할 때처럼 할당한 1차원 배열을 가리킬 포인터가 필요합니다. 그렇기 때문에 포인터형 배열을 먼저 할당을 해주는 것입니다.

 

 그럼 이제 내부에 1차원 배열을 할당해주도록 하겠습니다.

 

Ex> *p = new int[5];

 

 

stack 영역

int **p

0xCA00

0x23A3

heap 영역

new int*[4]

new int[5]

*[0]

[0]

[1]

[2]

[3]

[4]

0x07B0

쓰레기 값

쓰레기 값

쓰레기 값

쓰레기 값

쓰레기 값

0xCA00

0x07B0

0x07B4

0x07B8

0x07BC

0x07C0

*[1]

쓰레기 값

0xCA04

*[2]

쓰레기 값

0xCA08

*[2]

쓰레기 값

0xCA0C

 

 

예상과 다르게 할당되었으리라 생각합니다. 위에서 *p는 *(p + 0)과 같습니다. 먼저 할당한 포인터형 배열에서 첫번째 인덱스가 새로 할당한 공간을 가리키게되었습니다. 앞서 말했지만 할당은 1차원씩밖에 되지 않습니다. 저 문장 하나로 포인터 배열의 인덱스들 모두 1차원 배열을 할당받게 될 거라고 생각하셨다면 좋은 공부가 되었으리라 생각합니다. 왜 저렇게 되었는가를 좀 더 설명드리면 위에서 new를 하고나서 주소 값을 넘겨 준다고 했습니다. 그렇기 때문에 먼저 new int[5]에서 5칸의 int형 배열을 할당하고 할당한 배열의 첫번째 주소를 넘겨줍니다. 그럼 *p가 주소를 값으로 넘겨받습니다. 그럼 이 때 *p는 *(p + 0)과 같습니다. 그렇다면 int*의 배열에서 첫번째 인덱스에 현재 할당한 배열의 주소 값이 들어가게됩니다. 그렇다면 뒤에있는 나머지 인덱스들도 다 할당을 해주어야 제대로 사용을 하겠죠? 이 과정은 두가지 방법으로 코딩이 가능합니다.

 

 

Ex>   *(p + 0) = new int[5];                               for(int i = 0; i < 4; i++)

        *(p + 1) = new int[5];             =                 {

        *(p + 2) = new int[5];                                        *(p + i) = new int[5];

        *(p + 3) = new int[5];                                }

 

 

위에 두 코드가 왜 같은 지는 설명이 없어도 아시리라 생각합니다. 위에 코드를 보셨을 때 당연히 반복문이 더 간편하지만 상수가 아닌 변수를 사용해 변수에 들어 있는 값 만큼 할당을 하는 경우는 당연히 반복문으로 할당을 해주어야한다는 것을 알고 넘어가시면 되겠습니다. 변수에 4가 들어있을지 5가 들어있을지 모르기 때문입니다. 제가 예시는 상수로 크기를 정했지만 실제 활용에서는 변수로 사용합니다. 변수에 몇이 들어 있을지 알고 값이 일정한 경우에 사용한다면 당연히 동적할당을 할 필요가 없습니다. 정적으로 배열을 선언해놓고 쓰는 것이 훨씬 낫겠죠???(다시 말씀드리자면 동적할당의 목적은 사용자가 입력한 만큼의 공간을 할당해주거나 그때 그때 필요한 크기만큼의 공간을 할당하거나 추가적으로 조금 더 큰 크기로 할당을 하는 경우. 크기를 미리 예측할 수 없다는데에 있습니다.)

 

이 과정이 끝나면 다음과 같은 형태가 됩니다.

 

stack 영역

int **p

0xCA00

0x23A3

heap 영역

new int*[4]

new int[5]

*[0]

[0]

[1]

[2]

[3]

[4]

0x07B0

쓰레기 값

쓰레기 값

쓰레기 값

쓰레기 값

쓰레기 값

0xCA00

0x07B0

0x07B4

0x07B8

0x07BC

0x07C0

*[1]

[0]

[1]

[2]

[3]

[4]

0x7C10

쓰레기 값

쓰레기 값

쓰레기 값

쓰레기 값

쓰레기 값

0xCA04

0x7C10

0x7C14

0x7C18

0x7C1C

0x7C20

*[2]

[0]

[1]

[2]

[3]

[4]

0xAD00

쓰레기 값

쓰레기 값

쓰레기 값

쓰레기 값

쓰레기 값

0xCA08

0xAD00

0xAD04

0xAD08

0xAD0C

0xAD10

*[2]

[0]

[1]

[2]

[3]

[4]

0xC200

쓰레기 값

쓰레기 값

쓰레기 값

쓰레기 값

쓰레기 값

0xCA0C

0xC200

0xC204

0xC208

0xC20C

0xC210

 

 

모두 할당이 잘 되었습니다. 그리고 이 때 잘 생각해야하는 부분이 동적할당한 2차원 배열과 정적할당한 2차원 배열의 주소는 매우 다릅니다. 정적의 경우도 임의의 위치에 할당이 되지만 모두 연결이 되어서 잡힙니다. 하지만 동적의 경우 내부의 1차원 배열은 연속적이지만 각각의 1차원 배열끼리는 연속적이지 않습니다.

 

내부에 각각 접근하는 방법은 위에서 설명한 2차원 배열의 접근 방법과 동일합니다. [][]나 *(*())를 사용해서 하시면 됩니다.

 

그럼 이제 해제하는 방법에 대해 설명하겠습니다.

 

해제는 new와 반대로 delete를 사용하시면 되겠습니다.

 

Ex> delete p;     or     delete [] p;

 

일반 자료형 하나로 할당한 경우는 delete p  배열의 형태로 할당한 경우는 []를 붙여주시면 됩니다.

 

위의 과정을 표현해보자면 다음과 같습니다.(1차원 배열의 경우도 크게 차이가 없기에 생략하겠습니다.)

 

 

stack 영역

   heap 영역

int *p

delete

0x23AE

쓰레기 값

0xED12

0x23AE

 

 

 

해제하는데에 있어서 delete p;를 해주면 포인터 p가 해제된다고 생각하시는 분이 있는데 p가 가리키고 있는 공간이 해제되는 것입니다.아마도 new를 하면 p에 공간이 할당된다고 생각하는데에서 비롯되는게 아닐까 싶습니다.

 

 

그리고 이 때 p에 들어 있는 주소 값을 NULL로 초기화해주는 것이 좋습니다. 그대로 주소 값을 가지고 있다가 참조를 하면 할당을 해제한 공간을 참조하려하기 때문에 문제가 생깁니다. NULL에 참조해도 문제가 되지만 if(p != NULL)과 같은 조건문을 사용해서 예외처리를 하면 되기때문에 초기화 해주는 것이 좋습니다.(공간을 해제해주면 해제한 공간엔 쓰레기 값이 들어갑니다.)

 

이렇게 끝나면 해제도 참 쉽고 간단하겠지만 2차원 할당을 해줬던 것처럼 2차원 해제의 경우는 좀 더 해줄 것이 있습니다.

 

할당의 경우 먼저 위의 차원(포인터의 차수)의 공간을 할당하고 아래의 차원을 할당해주는 방식이었습니다. 아무래도 아래 차원을 먼저 할당해버리면 그 차원을 가리킬 포인터가 있어야하는데 아직 할당을 해주지 않았기에 아래 차원을 할당하더라도 이 주소를 기억하고 참조하는데 문제가 생기기때문입니다. 그럼 반대의 경우를 생각해보겠습니다. 먼저 높은 차원의 공간을 해제해버리면 그 아래의 차원의 공간을 어떻게 참조해야할까요? 그렇기 때문에 해제의 경우는 할당과는 순서가 반대가 됩니다.

 

Ex> for(int i = 0; i < a; i++) // a는 할당한 배열의 크기를 기억하는 변수.

{

delete[] *(p + i);

}

 

delete p;

 

위처럼 해제를 해줍니다. 위에서는 a라는 변수를 사용했는데 이전에 설명했듯이 크기를 알 수 없는 경우를 가장해 써보았습니다. 할당한 결과 [4][5]라는 크기일 때 a는 4라는 값을 기억하면 되겠습니다. delete [] *(p + i);가 실행되면 알아서 [5]의 공간을 해제해주기 때문에 앞에 [4]만큼 해제를 반복해주면 됩니다.

 

해제를 해주지 않아도 프로그램 자체에 문제는 발생하지 않습니다. 하지만 메모리 공간에 할당은 했지만 해제되지 않은 공간이 쌓여갑니다. 사용자가 운영체제에게 할당받아 사용중이기 때문에 운영체제에서도 스스로 따로 해제하지 않습니다. 계속 사용할 공간인지 아닌지 판단 할 수 없기 때문입니다. 계속 쌓이다보면 메모리 공간에 여분이 줄어들고 점점 컴퓨터가 느려집니다. 재부팅을 해주면 사라지긴합니다. 이렇게 할당을 하고 해제해주지 않는 경우를 메모리 누수라고 합니다.