포인터 변수는 주소를 담는 변수로서 다음과 같이 선언한다.
[CODE] 포인터 변수의 선언
int *p;
위와 같이 선언하게 되면 int형을 가리키는 포인터 변수 p가 만들어진다. 한편 포인터 변수 안에 변수의 주소를 대입하기 위해서는 &연산자를 사용하도록 한다. 이때 주소를 대입하려는 변수의 자료형은 포인터 변수가 가리킬 자료형과 같아야 한다.
[CODE] 변수의 주소 대입하기
p = &a;
그리고 포인터 변수에 담긴 주소를 사용하려면 * 연산자를 사용하면 된다. 이때 * 연산자는 binary 연산자로서 곱셈을 의미하는 것이 아니라 unary 연산자로서 주소값에 대한 접근을 의미한다.
[CODE] 변수의 주소 대입하기
*p = 50; // p가 가리키는 주소에 50 저장하기 ( a=50; 과 동일한 효과 )
var = *p; // p가 가리키는 주소에 저장된 값을 var로 불러오기 ( b=a;와 동일한 효과 )
처음에 포인터 변수 p를 int *p;로 선언했다는 것을 기억하는가? 이 선언문을 약간 분석하자면 포인터 변수 p 앞에 unary 연산자 *를 붙여서 *p와 같은 형태가 되었을때 이 변수가 int형이 된다는 의미가 된다. 즉, 변수를 사용하는 형태와 선언하는 형태가 같다는 것이다.
2. 포인터의 배열 vs 배열의 포인터
다음 두 코드의 차이점은 무엇일까?
[CODE] 서로 다른 두 선언문
int *(p[3]); // []연산자가 *연산자보다 우선순위가 높기 때문에 int *p[3]과 같다
int (*p)[3];
결론부터 말하자면 전자는 포인터의 배열, 후자는 배열의 포인터이다. 선뜻 이해가 안간다면 위에서 "사용하는 형태와 선언하는 형태가 같다"는 말을 다시 되새겨보며 위 코드를 다시 한 번 바라보도록 하자. 전자의 경우는 먼저 배열에 접근한 후, 그 배열의 원소에 * 연산자를 붙이면 int가 된다는 의미이다. 즉, 포인터 연산자를 통해 접근할 수 있는 경우의 수는 3가지가 된다. 후자는 먼저 * 연산자를 붙인 다음에 배열 인덱스로 접근한다는 의미가 된다. 이는 포인터 연산자로 접근할 수 있는 경우의 수는 1가지이며 그 이후에 배열 인덱스로 여러개의 int형 자료에 접근하게 것이다. 말이 꼬이기 시작하는데, 직관적인 이해를 돕기 위하여 아래에 그림을 첨부하도록 하겠다.

▲ 포인터 배열 ( 포인터 변수의 배열 )

▲ 배열의 포인터 ( 배열을 가리키는 포인터 )
3. 배열과 포인터의 관계
다음과 같이 원소 5개짜리 배열 A가 선언되어 있다고 생각해보자.
[CODE] 배열의 선언
int A[5];
이 배열의 각 원소에 접근하기 위해서는 A[0], A[1], ... , A[4] 따위로 접근하는 것은 잘 알고 있을 것이다. 그렇다면 그냥 인덱스를 쓰지 않고 A라고만 하면 무엇을 나타내는 것일까? 바로 배열의 시작 주소를 나타내는 값이 된다.
즉, A == &A[0] 인 셈이다. 그리고 이 주소를 기반으로 하여 각 인덱스의 원소에 접근할 수 있는 것이다. 다시 말하면
A[i]의 주소 = A + i × (자료형의 크기)
다시 포인터로 돌아가서 다음과 같은 코드가 A의 선언문 바로 밑에 있다고 생각해보자.
[CODE] 포인터의 선언 + 배열 주소 대입
int *p = A;
이렇게 하면 포인터 변수 p는 A의 첫번째 원소를 가리키게 된다 (왜냐면 A == &A[0]이므로). 그렇다면 여기서 다음과 같이 p를 1 증가시키면 어떻게 될까?
[CODE] 포인터 변수의 증가 연산
p = p + 1; // 또는 ++p;
어떠한 일이 벌어졌을까? p에 저장된 주소값은 1 증가했을까? 그렇지 않다. 아마도 32bit 운영체제라면 p의 값은 4가 증가했을것이다. 이유는 위에서 p는 int를 가리키는 포인터 변수이고 하나의 int는 4bytes를 차지하고 있기 때문이다. 다시 말하면,
p + i의 주소 = p + i × (p가 가리키는 자료형의 크기)
어디서 많이 본 형태 아닌가? 바로 일차원 배열과 똑같다! 실제로 위 연산을 마친 뒤 p가 가리키는 것은 A[1]이다. 이러한 배열과 포인터의 유사성 때문에 포인터 변수에 직접 []연산을 하는 것이 허용된다.
[CODE] 포인터 변수에서 []연산자의 사용
p = A;
var = p[1]; // var = A[1];과 동일한 효과
4. 동적 할당
배열을 선언해서 사용하게 되면 크기가 컴파일시 고정이 되기 때문에 유동성을 갖춰야 하는 프로그램이라면 어려운 점이 많다. 하지만 이제 포인터의 정체와 이 포인터를 배열과 같이 사용할 수 있다는 점을 알았으니 라이브러리 함수를 이용하여 그때 그때 메모리를 할당받아 배열을 생성할 수 있게 되었다. 보통 C에서는 malloc을, C++에서는 new 연산자를 사용한다.
[CODE] n개의 원소를 가진 int형 배열 할당하기
int *p1 = (int*) malloc( n * sizeof( int ) ); // malloc 사용하기
int *p2 = new int[n]; // new 연산자 사용하기
주의할 점은 동적할당을 하는 경우에는 반드시 다 쓰고난 뒤에는 할당해제를 해줘야 한다는 것이다. 그렇지 않으면 메모리 누수가 일어나 심각한 결과를 초래할 지도 모른다. (특히 프로그램의 규모가 커질수록 더)
[CODE] 동적할당받은 배열 되돌려놓기
free( p1 ); // malloc 사용하여 할당한 경우는 free를 이용하여 해제
delete[] p; // new을 이용하여 할당한 배열은 delete[]를 이용하여 해제
위와 같은 방법으로 하면 일차원배열밖에 할당받지 못한다. 2차원 또는 그 이상의 배열을 할당받기 위해서는 어떻게 해야 할까?
먼저 포인터의 배열을 선언한 후 각각의 포인터에 대하여 다시 배열을 할당받는 방법이 있다. 아래의 예제들에서는 예제들 간의 통일성을 지키기 위하여 #define을 이용한 매크로를 썼으나 실제로는 선언부에 배열크기로 들어가는 인덱스성분이 아니라면 변수를 사용하여야 진정한 의미의 동적할당이 될 것이다. (동적할당의 취지가 런타임 시점에 임의의 크기의 메모리를 할당받는 것이므로) 이 방법으로 배열을 동적할당받게 되면 행크기가 고정이 된다. 따라서 아래의 예제에서는 N은 고정값이 되며 M은 변수로 대체가 가능하다.
[CODE] 5×4크기의 2차원 배열 할당받기 #1 : 포인터의 배열 사용
#define N 5
#define M 4
int i, j;
int *p[N];
for( i = 0; i < N; ++i )
p[i] = ( int* ) malloc( M * sizeof( int ) );
이 경우 할당해제를 할 때에도 각각의 포인터에 대하여 일일히 해제해 주어야 한다.
[CODE] 5×4크기의 2차원 배열 할당받기 #1 : 포인터의 배열 사용 (해제하기)
for( i = 0; i < N; ++i )
free( p[i] );
두번째 방법은 배열의 포인터(배열을 가리키는 포인터)를 이용하는 방법이다. 포인터 변수에 []연산자를 사용하게 되면 그 포인터 변수가 가리키는 자료형의 크기에 비례하여 offset이 움직인다는 사실을 알고 있다. 배열의 포인터는 배열을 가리키는 포인터이기 때문에 []연산자를 통하여 인덱스를 지정해주게 되면 배열 크기 단위로 offset이 이동하게 된다. 이 방법을 통하여 할당을 받게 되면 동적할당을 받는 열의 크기가 고정이 된다. 따라서 아래의 예제에서는 N은 변수로 대체가 가능한 반면 M은 컴파일시에 결정되는 고정값이다.
[CODE] 5×4크기의 2차원 배열 할당받기 #2 : 배열의 포인터 사용
#define N 5
#define M 4
int i, j;
int (*p)[M];
p = ( int(*)[M] ) malloc( N * sizeof( int[M] ) );
이 경우에는 할당을 한 번 밖에 받지 않았기 때문에 다 쓰고 나서는 p만 할당해제 해주면 된다.
[CODE] 5×4크기의 2차원 배열 할당받기 #2 : 배열의 포인터 사용 (해제하기)
free( p );
주의해야 할 것은, 첫번째 방법에서 할당받은 것은 실제로는 2차원 배열이 아니다! 단지 포인터의 배열을 이용하여 이차원배열과 유사한 이용법을 구현해 낸 것에 불과하다. 첫번째 방법을 통해 메모리를 할당받은 뒤에 p[2][3];를 하는 것과 두 번째 방법으로 메모리를 할당받고 p[2][3];를 하는 것은 내부적으로 완전히 다른 연산을 거친다는 사실을 알아두자.
첫번째 방법은 자칫 번거롭고 오리지널 이차원배열도 아닐 뿐더러 포인터 여러개를 할당받음으로써 메모리를 더욱 낭비하는 것처럼 보일 수 있으나, 이를 응용하면 행과 열 크기 모두 동적으로 할당할 수 있다는 장점이 있다. 아래의 예제에서는 N, M 모두 변수로 대체가 가능하다.
[CODE] 5×4크기의 2차원 배열 할당받기 #3 : 이중포인터 사용
#define N 5
#define M 4
int i, j;
int **p;
p = (int**) malloc( N * sizeof( int* ) );
for( i = 0; i < N; ++i )
p[i] = ( int* ) malloc( M * sizeof( int ) );
이 경우 할당해제를 할 때에는 할당한 순서와 반대로 해제를 해 준다. 그렇지 않으면 포인터 배열이 할당해제를 하기 전에 이미 날아가버리게 되어 치명적인 오류를 발생시킬 수 있다.
[CODE] 5×4크기의 2차원 배열 할당받기 #3 : 이중 포인터 사용 (해제하기)
for( i = 0; i < N; ++i )
free( p[i] );
free( p );
댓글 없음:
댓글 쓰기