C

[C/C++] 한 편으로 요약하는 포인터

한편으로 요약하는 포인터

C의 포인터는 사실 단순히 ‘메모리상의 주소를 저장하는 변수’의 개념인데, 실제로는 상당히 어렵다고 느끼는 경우가 많다. 왜냐면 메모리 주소를 사용하는 방식은 단순히 ‘간접적’으로 변수 값을 참조하는 것이라 “과연 이걸 어디다, 왜 쓴단 말인가”라고 생각해버리기 쉽기 때문에 실제로 어떻게 써야 하는지에 대한 감을 잡기 힘들기 때문이다. 이 글에서는 몇 개의 예제를 살펴보고 이를 통해 포인터의 기초적인 내용을 공부해 보도록 하겠다.

이상한 루프 – ptr00.c

[gist id=fe77f2964ef9ae43beee file=ptr00.c]

a라는 변수(배열)에 1~9까지의 숫자를 담았다. 다른 언어와 비슷하게 C의 배열도 인덱스 번호를 통해 배열 속의 한 원소에 접근할 수 있다. a[i] 와 같은 방식으로 말이다. 이 예제의 for루프 속에서 배열의 각 원소값은 i[a]로 참고하고 있다. 하지만 이 코드는 오타라기보다는 의도한 것이며, 컴파일했을 때 정상적으로 동작한다. 정상적으로 동작한다에서 슬슬 멘붕이 오려고 한다면 포인터에 대한 이해를 준비해야 하는 시점이 온 것이라 봐도 좋다.

포인터는 주소를 의미한다 – ptr01.c

포인터는 C에서 가장 어려운 개념이라고 흔히 알려져 있는데, 실제로는 매우 간단한 개념이다. 포인터는 “메모리의 주소 정보를 담는 변수형”일 뿐이다.

흔히 사용하는 scanf 함수를 보자. 이 함수는 사용자의 키보드 입력으로부터 값을 입력받는 함수이다. 이 함수는 예제와 같이 값을 저장하고자 하는 변수 앞에 &를 붙인다.

scanf("%d", &a);

변수명 앞에 &를 붙이는 것은 변수의 메모리 번지를 나타내는 것을 의미한다…라는 설명은 C 관련 서적에서 흔히 볼 수 있는 설명이다. 이 함수가 하는 일은 다음과 같다.

  1. 키보드를 통해 사용자로부터 어떤 값을 입력받는다.
  2. %d“는 정수를 뜻하므로 숫자를 입력받아, 그 숫자값을 &a즉 a라는 변수가 점유하고 있는 메모리 번지에 쓴다.

이렇게 하면 사용자로부터 입력받은 숫자가 ‘수’로 변환되어 a라는 변수에 대입되는 것과 같은 효과를 같는다.

자, 이렇게 이미 우리는 ‘메모리 주소 번지’를 사용하는 법은 진작에 알고 있었다.

[gist id=fe77f2964ef9ae43beee file=ptr01.c]

다시 예제를 보자. b라는 변수는 조금 특이하게 선언되었다. int *b에서 *는 이 변수가 그냥 int 형 변수가 아닌 int 포인터타입이라는 것을 명시한다. 다시 한 번 말하지만 그냥 포인터가 아니라 int 형의 포인터라는 타입이 있는 것이다. 따라서 이 구문의 띄어쓰기는 보다 정확히는 다음과 같이 쓸 수 있다. int* b;

어쨌든 b는 정수형 변수의 메모리 번지를 저장하는 포인터 변수가 되었다. 코드를 계속 읽어보면 scanf 함수를 통해 변수 a에 사용자로부터 입력받은 숫자값을 대입하게 된다. 그리고 b = &a를 통해 포인터 b는 변수 a의 주소값을 저장하게 되고, 출력은 *b로 하게 된다.

포인터 변수 앞에 *를 붙이면 이는 해당 변수가 저장하고 있는 메모리 번지에 있는 값을 읽어온다는 의미이다. 즉 다음과 같은 관계가 성립한다.

  • int a; int *b;
  • b = &a; // 포인터는 변수의 주소값을 대입한다.
  • a == b; //변수 a의 내용은 b와 같다.

여기까지가 포인터에 대한 내용의 절반쯤이다. 그리고 여기까지는 그닥 어려워 보이지는 않는다. 왜냐하면 포인터는 그저 변수를 간접적으로 한 단계 더 거쳐서 사용하는 것일 뿐이라서 굳이 쓸 이유가 있겠나…. 싶다. 뭐 이에 대한 이유는 곧 설명할텐데, 우선은 “포인터 변수는 포인터 타입”으로 선언되는, 원래 변수의 타입과는 다른 변수라는 것만 기억하면 된다.

배열과 포인터 – ptr02.c

앞서 살펴본바로는 포인터는 그저 ‘변수의 메모리 주소’를 통해서 그 변수에 대입되어 있는 값을 참조하는 것으로 그닥 대단한 것이 아니었다. (실제로 포인터는 그게 전부다) 하지만 몇 가지 다른 사실들과 결합되면서 활용도가 급상승하게 된다. 그리고 이 포인터와 관련된 사실들을 좀 더 명확하게 이해하기 위해서는 “그저 그런 것들로 보였던 아주 기본적인 지식들이 중요하게 기능하기 시작한다.

[gist id=fe77f2964ef9ae43beee file=ptr02.c]

이 예제는 배열에 담긴 값을 차례로 표시하는 루프를 기술하고 있다. 다만 차이가 있다면 배열의 인덱스(a[i])를 사용하는 것이 아니라 포인터를 사용하고 있다. 이 코드를 이해하기 위해서는 몇 가지 알아두면 좋은 사실들이 있는데, 이는 다음과 같다.

  • 배열을 선언하면, 배열의 크기만큼 메모리상의 연속적인 공간이 할당된다.
  • 배열의 변수명은 배열의 시작 번지를 가리키는 것으로 해석된다. 이는 매우 중요한 사실이다.
  • 포인터에는 정수 값을 더하거나 뺄 수 있다. 이는 포인터가 가리키는 특정 메모리 번지의 다음 주소 혹은 이전 주소를 말하게 된다.

배열이 메모리상에서 연속적인 공간을 이루고 있다는 사실은 기본적이면서도 매우 중요하다. 사실 a[7]과 같이 배열의 어떤 원소에 대해 직접적인 임의 접근이 가능하다는 사실은 배열이 연속적인 구조로 되어 있다는 것에 기인한다.

코드에서 정수형 포인터로 선언된 i에는 배열 변수 a가 대입된다. 배열의 변수명은 배열의 시작 지점의 메모리 주소와 같은 값이므로, i = a;라는 저 구문은 제대로 된 구문이다. 따라서 *i는 a의 번지에 들어있는 값이며 이는 곧 a[0]과 동일한 표현이다.

9행에서는 포인터의 값을 1씩 증가시키면서 배열의 각 요소값을 출력해주고 있다. 맨 처음 i는 a와 같은 번지값을 가리키고 있는데, 여기에 1씩 더해지면서 배열의 다음 요소의 위치를 가리키게 된다. 사실 이것은 마술과도 같이 느껴질 수 있다. 왜냐하면 int타입은 정수의 자료형으로 써 개당 4바이트씩을 담기 때문이다. i에 4가 아니라 1씩만 더해도 올바른 값을 참조할 수 있는 이유는 i 가 정수형 포인터로 선언되었기 때문이다.

즉 각각의 자료형에 대한 포인터 타입을 따로 선언하는 것은, 포인터 그 자체의 타입에 의해 1을 더할 때 메모리에서 다음 지점을 어디를 찾아봐야 할지를 알고 있는 것이다.

char 형은 1바이트 크기를 차지하는 자료형이므로 char*형으로 선언된 포인터는 1씩 더할 때 마다 현재 주소값에서 1을 더하게 되고, double*형으로 선언된 포인터는 1씩 더할 때마다 현재 주소값에 대해 8씩 더하게 된다.

따라서, 위의 알아두면 좋은 사실과 더불어 다음 사항도 꼭 기억하도록 한다.

  • 포인터에 더하는 정수값은 내부적으로 해당 포인터의 자료형의 크기만큼 곱해지게 된다.
  • 배열을 인덱스로 참조하는 a[i] 문법은 포인터를 사용한 *(a+i)와 완전히 동일하다.

자 이제 비밀이 풀렸나? 맨 처음 코드에서 a[i]로 쓰는 대신 i[a]로 쓰는 것은 둘 다 똑같이 *(i+a)가 되므로 올바르게 동작하는 코드가 되는 것이다.

동적으로 크기가 정해지는 배열 – ptr03.c

[gist id=fe77f2964ef9ae43beee file=ptr03.c]

포인터는 연속적인 메모리 공간을 탐색하고 값을 얻어내거나 조작하는데 있어서 직접적인 방법을 제공해 주기 때문에 이런 특성을 가진 배열(나아가서는 문자열)과 결합할 때 상당히 유용하게 사용될 수 있다.

배열은 많은 장점을 가진 자료형이지만, 한가지 단점이 있는데 바로 배열의 크기가 컴파일 시점에 정적으로 정해진다는 것이다. 하지만 프로그램의 실행 시점(런타임)에서는 필요한 배열의 크기가 제각각일 수 있다. 물론 임의로 아주 큰 배열을 선언해둘 수는 있지만, 고작 열 개 남짓되는 자료에서부터 수 백개에 이르는 자료까지 그 폭이 심하게 들쭉 날쭉하다면 어떤 시점에서는 배열이 너무 작을 수도 있고, 어떤 시점에서는 너무 큰 메모리가 낭비될 수도 있다.

하지만 포인터를 사용하면 필요한 시점에 필요한 크기만큼의 메모리를 할당할 수 있고 이를 배열처럼 사용하는 것이 가능하다. 이 예제는 사용자로부터 입력받은 값만큼의 메모리를 할당하고 여기에 숫자값을 대입해준다. 즉 실행 시점에 배열의 크기를 결정하는 예이다.

문자열과 포인터 – ptr04.c

[gist id=fe77f2964ef9ae43beee file=ptr04.c]

이 예제는 포인터를 문자열과 결합하여 사용하는 일련의 방법을 보여준다. 기본 라이브러리에 포함된 strcpy를 흉내내면서 대신에 toupper (ctype.h에 정의되어 있다.)를 사용하여 대문자로 변환된 사본을 만들어주는 함수를 쓰고 있다.

문자열을 담기 위해서는 실제 글자 수 보다 1바이트가 더 필요한데, 실제로 배열은 그 끝을 알 수 없기 때문에 문자열에서는 맨 끝에 종료값(0)을 추가해주기 때문이다. 만약 12행을 주석처리하고 이 코드를 컴파일해서 실행해보면 “HELLO WORLD!” 뒤로 몇 개의 쓰레기값이 추가로 더 표시되는 것을 볼 수 있다. (0값을 만나기 전까지)

이중 포인터 – ptr05.c

[gist id=fe77f2964ef9ae43beee file=ptr05.c]

이 예제는 포인터의 포인터, 즉 이중 포인터를 사용하는 예제를 보여준다. 여기서 등장하는 함수 changeString은 포인터의 포인터를 인자로 받아서, 이 포인터의 내용을 다른 번지로 바꿔준다. 이 코드를 실행하면 char형 포인터인 b의 주소값을 함수로 던지고, 함수에서는 이 값을 **s로 받는다.

  • s == 변수 b의 주소 값이 된다.
  • *s == 변수 b의 내용, 즉 “hello world”라는 문자열의 메모리상의 시작 번지를 가리킨다.
  • *s == b 가 되므로 b의 번지에 담겨있는 값, 'h'가 된다.

함수 changeString내에서 b에 담겨있던 주소 값은 새로운 문자열인 “This is not Hello World”의 시작 번지를 가리키도록 조작된다. 결국 출력되는 문자열은 b가 가리키는 새로운 주소에 있는 문자열인 This is not Hello World가 된다.

만약 이중 포인터를 사용하지 않으면 다음과 같은 코드가 되리라 예상해 볼 수 있다.

void changeString(char *p)
{
    &p = "This is not Hello World!";
}

하지만 이 코드는 동작하지 않는다. 왜냐면 변수 앞에 &을 붙인 &p는 p라는 변수의 현재 주소값을 나타내는 상수값이기 때문에 변경할 수 없다.

따라서 이중 포인터는 포인터로 선언된 변수를 함수로 넘겨, 포인터가 가리키는 주소 자체를 함수 내에서 변경하고자 할 때 쓰인다.

자주 쓰이는 기법은 아니고, 주로 함수가 어떤 작업을 처리하다가 예외가 발생할 때 에러와 관련된 정보를 함수 외부로 보내줄 때 사용한다. 왜냐하면 함수는 리턴 값이 없거나 (void 형) 아니면 다른 고정된 반환형을 가지게 되는데 보통은 에러를 반환하는 디자인은 좋지도 않고 하기도 힘들기 때문이다.

2차원 배열, 배열의 배열 – ptr06.c

[gist id=fe77f2964ef9ae43beee file=ptr06.c]

이중 포인터는 이 외에도 가변적으로 선언해야 하는 2차원 배열이 있을 때 사용할 수 있다. 문자열은 그 자체로 배열인데, 이런 문자열들을 원소로 가지는 배열을 만들어야 할 때 2차원 배열을 사용한다. char name[30][50]은 크기가 50인 문자열 30개를 담을 수 있는 배열을 선언하는 것이며, 컴파일러는 미리 30*50 바이트의 메모리를 이 배열을 위해 할당한다. 하지만 이름이 꼭 50글자를 채우는 것은 아니며, 어떤 경우에는 50자를 넘을 수도 있다. 또한 저장해야 할 이름이 항상 30개인 것은 아니며, 어떤 경우에는 그 이상의 이름을 저장할 배열이 필요할 수 있다.

만약 어떤 1차원 배열이 있고, 이것이 char형 포인터의 배열이라면, 배열의 각 요소는 각각 다른 문자열의 시작 번지를 가지고 있으면, 우리는 마치 이것을 문자열의 배열처럼 사용할 수 있다. 다시 이 1차원 배열을 포인터를 사용하여 동적으로 할당할 수 있는 형태로 만든다면, 여기서 설명하는 이중 포인터를 사용하는 방식이 되는 것이다.

코드의 내용은 복잡해 보이지만 실제로는 꽤 명료한 편이다.
먼저 char의 이중 포인터 변수인 s를 선언했다. 그리고 여기에는 10개의 문자열을 저장하려고 한다. 그렇다면 s에는 메모리 공간을 미리 할당해 주어야 하는데, (char *)malloc(10 * sizeof(char *))라고 썼다. 즉 char *타입의 값을 10개 저장할 수 있는 영역이 되는 것이다. 주의해야 할 점은, char 타입은 *char 형 변수의 메모리 번지를 저장하는 타입이므로 1바이트가 아니라는 것이다. 시스템마다 다를 수는 있는데, 통상 unsinged long int형을 쓴다. 이 타입은 32비트 시스템에서는 4바이트에 해당하는 크기를 가지며, 64비트 시스템에서는 8바이트에 해당하는 크기를 가진다.

배열을 위한 메모리를 할당한 다음에는, 다시 s의 각 요소인 *(s+i)에 대해 9자의 문자를 담을 수 있는 메모리 공간을 할당한 후, 여기에 문자열을 복사해 넣었다. 그리고 각각을 출력한 후 할당한 메모리를 해제하였다.

실제로 메모리의 사용은 32비트 기준에서는 4바이트 짜리 공간이 연속적으로 10개가 할당되고, 이 공간에서는 흩어져 있는 다른 메모리 공간에 대한 주소값을 저장할 뿐이다. 출력 형식을 printf("%s\n", s);가 아니라 printf("%x\n", *s);로 한다면 배열에 실제 저장된 각 문자열의 시작 번지가 출력되는 것을 확인할 수 있다.

구조체 내에 크기를 정할 수 없는 공간을 할당해야 할 때 – ptr07.c

[gist id=fe77f2964ef9ae43beee file=ptr07.c]

구조체는 다른 타입의 자료들을 묶어서 하나의 꾸러미처럼 다룰 수 있는 C의 중요한 자료형 중 하나이다. 예를 들어 주소록과 같은 프로그램을 만드려면, 친구의 이름, 전화번호, 메일 주소 등을 저장하는데 이를 하나의 친구별로 묶음으로 취급할 수 있다면 꽤나 편리할 것이다.

문제는 구조체 타입의 변수를 선언하면, 컴파일러는 그에 맞는 메모리를 미리 할당해야 하는데, 가변 크기의 배열 (예를 들어 char name[];과 같은)을 구조체 내에서 사용하며, 할당해야 할 메모리의 크기를 미리 알 수 없으므로 컴파일러는 에러를 내게 된다. 역시나 이름의 길이는 특정하기가 힘드니, 이럴 때도 포인터를 사용하면 된다. 왜냐하면 포인터 타입은 메모리의 번지를 저장하므로 항상 고정된 크기를 갖기 때문에 구조체의 크기는 항상 고정된 상태이며, 추후에 이름을 입력하기 위해서는 필요한 크기 만큼 메모리를 할당해 주고 이 곳에 문자열을 복사하면 되는 것이기 때문이다.

포인터 상수와 문자열 – ptr08.c

[gist id=fe77f2964ef9ae43beee file=ptr08.c]

char형 포인터를 사용하여 문자열을 저장하는 경우 strcpy 함수를 사용했는데, 사실 char *s = "hello world";와 같은 식으로 대입해도 되는데 왜 굳이 메모리를 할당하고 문자열을 복사하는 불편한 루트를 거치게 되는 것인지 궁금해 할 수도 있을 것 같다. 역시나 이는 변수의 타입과 관련된 항목이다.

문자열을 복사하는 함수 strcpy의 원형을 살펴보자.

void strcpy(char * dest, const char *source);

인자의 이름은 정확하지 않을 수 있는데 대략 이런 모양이다. 그래서 이 함수가 하는 일은 두 번째 인자로 받은 위치에 있는 문자열을 첫 번째 인자가 가리키는 메모리 주소에 복사하게 된다.

그런데 만약, char *s="hello world"라고 했다가 다시 strcpy(s, "Not world")라고 하면 이 부분에서 에러가 난다. 이 부분을 이해하기 위해서는 프로그램의 메모리 영역에 대한 이해가 필요하다.

프로그램을 실행하면 먼저 프로그램 코드가 메모리에 로드된다. 그리고 위의 구문에서와 같이 프로그래머가 정적으로 선언한 문자열이나 각종 상수로 취급되는 값들이 로드된다. 이 영역은 정적 메모리 영역으로 프로그램에서 사용하는 상수 값들이 들어가게 된다. 그리고 이 영역은 “읽기만 가능”하도록 보호된다. 즉 이 타입은 char *가 아니라 const char *이다. 이 영역을 덮어 쓰려고 하기 때문에 오류가 생기는 것이다.

대신에 배열을 통해 정적으로 선언한 문자열을 정적 메모리 영역이 아닌 heap 영역에 선언되기 때문에 추후에 내용을 바꾸는 것이 가능하다.

배열 변수와 포인터는 다르다 – ptr09.c

[gist id=fe77f2964ef9ae43beee file=ptr09.c]

많은 C관련 서적에서 배열의 변수 이름은 “배열이 시작하는 번지”를 가리키고 있어서 포인터와 동일하다라고 설명하는데, 이는 잘못된 설명이다. (그냥 자세히 설명하기 귀찮아서 이런 식으로 쓴 것 같다.)

int a[20] 이라고 선언한 배열의 타입은 int[20]이다. 타입이 다르다는 것은 sizeof함수를 통해서 확인할 수 있다. 예제는 배열로 선언한 배열과, 포인터로 선언하여 메모리를 할당한 배열 두 개에 대해서 sizeof 함수로 크기를 비교하고 있다.

먼저 배열로 선언한 변수에 대해 sizeof 함수를 적용하면 배열의 전체 크기가 나온다. 이는 설명했듯이 변수의 타입에 크기가 명시되어 있기 때문이다. 그렇지만 malloc으로 할당한 포인터는 (예제의 경우에서는) char *형 이므로 그 타입은 unsigned long int로 고정된 크기 -32비트 시스템 기준으로 4바이트-가 된다.

배열의 길이를 구할 때는 sizeof(arr) / sizeof(arr[0])으로 구할 수 있지만, 포인터를 사용하는 경우에는 이 방법을 사용할 수 없다. 메모리를 할당하는 시점에 이미 프로그래머는 할당하는 크기를 알고 있으므로, 이를 그대로 사용해야 한다.

동적할당한 메모리 영역의 크기를 알아내는 법 – ptr10.c

앞서 9번 예제에서 할당한 메모리의 크기는 할당 시점에 이미 알고 있기 때문에 나중에 그 값을 그대로 써야 한다고 했다. 하지만 만약 메모리가 함수 내에서 할당되었다던가 하는 경우에는 이 크기 값을 받기 위해 이중 포인터를 쓰는 방식 등으로 함수를 디자인해야 하는 등 여러 가지 제약 사항이 발생하게 된다.

[gist id=fe77f2964ef9ae43beee file=ptr10.c]

이 경우에 활용할 수 있는 방법은 malloc과 유사한 동작을 하는 함수를 새로 하나 정의하는 것이다. 본 예제에는 이런 문제를 해결할 수 있는 lalloc 이라는 함수를 소개한다. 이 함수가 하는 동작은 사실 간단한데, 다음과 같은 방식으로 동작한다.

  • 메모리의 크기는 size_t라는 타입을 사용한다. (malloc 함수의 원형을 보면 알 수 있음)
  • 실제로 메모리를 필요한 크기보다 size_t 만큼 더 할당한다.
  • 할당된 메모리의 맨 첫 영역에 크기값을 저장한다.
  • 그리고 그 영역의 바로 다음 번지를 리턴한다.

이렇게 하면 함수를 호출해서 쓰는 쪽에서는 malloc과 동일한 방식으로 호출해서 메모리를 할당받고 이를 사용하면 된다. 대신에 이 영역의 선두 바로 앞 쪽에서는 할당한 메모리 영역의 크기가 얼마인지를 담고 있는 정보가 있기 때문에 언제든지 포인터 연산을 통해 이 값을 확인할 수 있다. win32에서는 _msize()라는 함수가 있는데, 표준 함수가 아니므로 이런 방식으로 할당한 메모리의 크기를 뒤에 다시 참고해야 하는 경우에 이런 방식을 사용할 수 있다.