콘텐츠로 건너뛰기
Home » 한 편으로 이해해보는 포인터(C)

한 편으로 이해해보는 포인터(C)

C를 공부하는 많은 사람들이 입버릇처럼 ‘포인터는 어렵다’고 말한다. 기술적으로 포인터는 사실 단순히 메모리상의 주소를 가리키는 정수값일 뿐이다. 그런데 왜 이것이 어려울까? 사실 포인터 자체는 단순하지만, 포인터를 이해하기 까지에는 제법 많은 배경지식이 추가로 필요하기 때문일 것이다. 오늘 이 글에서는 각잡고 포인터를 이해하는데 필요한 내용들을 정리하여 전달하고자 한다.

이상한 루프

다음 예제 코드를 먼저 살펴보자.

// ptr00.c
#include <stdio.h>
int main(void) {
  int a[9] = {1,2,3,4,5,6,7,9};
  int i;
  for(i=0;i<9;i++) {
    printf("%dn", i[a]);  // a[i]가 아님???
  }
  return 0;
}

위 예제에서 a는 int[] 타입의 배열로 1에서 9까지의 정수값을 담고 있고, for 루프를 통해서 배열의 값을 하나씩 출력한다. 그런데 좀 이상한 부분이 있다. 인덱스를 사용해서 i 번째 요소를 액세스하는 것을 a[i]가 아니라 i[a]로 썼다. 분명 이것은 두 글자를 바꿔 타이핑한 오타처럼 보이지만, 이 코드는 명백하게 정상적인 코드이며 실제로 컴파일해서 실행도 문제없이 된다. 어째서 저 이상한 코드가 올바른 코드가 될 수 있을까? 그 비밀은 엉뚱하게도 포인터와 관련이 있다.

메모리에 대해 알아보자

많은 사람들이 어렵다 어렵다 노래를 부르는 포인터는 실제로는 별 거 아니다. 말 그대로 ‘무언가를 가리키는 것'(Pointer)이다. 그럼 무엇을 가리키나? 특정 메모리의 주소(address)를 가리킨다. 메모리의 주소는 정수로 그 순서를 나타내는 것으로 결국 포인터란 정수값을 저장할 수 있는 변수에 지나지 않는다.

컴퓨터에서 인식할 수 있는 모든 것은 0과 1로 이루어진 이진수 데이터이다. 그리고 프로그램이 사용하는 모든 것은 메모리에 올려지게 된다. 이것은 변수나 상수와 같은 ‘값’ 뿐만 아니라 프로그램의 실행 코드 역시 마찬가지로 적용받는 규칙이다. 만약 컴퓨터가 (혹은 프로그램이) 어떤 데이터를 사용하려고 한다면 먼저 그것이 ‘어디에’ 있는지 알아야 할 것이다. 메모리에서 ‘어디에’에 해당하는 정보가 메모리 주소이다.

0혹은 1, 그 한 개로 표현하는 정보의 단위를 ‘비트’라고 하고 이 비트가 8개 모인 것을 바이트라고 한다. 이건 보통 C 책 맨 앞에 나오는 (그리고 hello world 코드가 나오기 전까지는 그냥 스킵하게 되는) 부분에 꼭 나오는 이야기이다. 바이트는 램 용량의 단위이기도 하기 때문에 컴퓨터 분야에서 뭐 꼭 전공자가 아니더라도 많이 듣는 단어일 것이다. 그럼 갑자기 왜 바이트 이야기를 꺼내느냐면, 그것이 중요하기 때문일 것이며, 바이트가 중요한 이유는 컴퓨터가 메모리를 액세스하는 최소단위가 바이트이기 때문이다.

비트를 실제로 물리적인 장치로 표현하기 위해서는 어떤 반도체 소자(어떻게 생겼는지는 관심 밖이니까 각자 알아서 멋진 장치 같은 걸 상상하자)가 있어서 여기에 전기가 통하면 1, 그렇지 않으면 0으로 인식될 수 있을 것이다. 이런 장치 1개가 있으면 두 가지 정보 ‘있다’, ‘없다’를 표현할 수 있다. 2개가 있으면 00, 01, 10, 11 의 4가지 정보를 표현할 수 있다. 8개가 있으면 2의 8승 가지수, 즉 256가지의 다른 정보를 나타낼 수 있다. PC에 달려있는 메모리는 이러한 소자가 엄청나게 많이 달려있는 거대한(?) 장치이나, 그 전체 용량이 얼마가 됐든 이러한 소자를 8개씩 묶어서 한 단위로 취급한다. 그리고 그 8개 마다 0번 부터 1, 2, 3, … 이렇게 번호를 붙여서 순서대로 제어한다.

8비트라고 하는 것은 약간 달리 표현하면 8자리 이진수 숫자인 셈이다. 우리가 세자리 자연수라고 말하면 100~999를 생각하는데, 백의 자리와 십의 자리를 0으로 채울 수 있는 상황을 가정하면 000~999까지 1,000 가지의 경우를 표현할 수 있을 것이다. 마찬가지로 8비트는 8자리로 표현할 수 있는 이진수 0000 0000 ~ 1111 1111 (읽기 편하게 4자리 단위로 띄워씀) 이며 이는 십진수로 0~255까지의 범위가 된다.

참고로 4자리 2진수는 0~15까지의 범위이고 이것은 16진법으로 환산했을 때 0~F로 한자리로 표현 가능하다. 따라서 1바이트는 2자리 16진수(00~FF)로 표현가능하다는 점도 알아두면 좋다.

자료형의 의미

메모리에 대해서 이야기한 김에 C의 자료형에 대해서도 이야기해보자. C의 자료형에는 char, int, float, double 등 여러 가지가 있다. C를 설명하는 많은 책에서 이러한 자료형을 이야기할 때 자료형의 크기에 대해서 언급하는 부분이 있다. 예를 들자면 char 타입은 1바이트의 크기를 갖는다. 그리고 int 타입은 (플랫폼에 따라 다를 수 있으니 주의해야 하지만) 보통 4바이트의 크기를 갖는다.

char 타입이 1바이트의 크기를 갖는다는 말은 크게 두 가지 의미로 이해할 수 있다. 먼저 1바이트는 8비트이기 때문에 char 타입의 값은 256가지가 표현 가능한 최대 개수라는 점이다. 또 0, 1 혹은 2, 3과 같이 표현하는데 비트가 아무리 적게 들어가더라도 char 타입이라면 이 값을 표현하고 저장하기 위해서는 1바이트의 메모리가 항상 쓰인다는 점이다. 마찬가지로 4바이트 int 타입에 대해서도 1을 쓰든 2백만을 쓰든 항상 4바이트의 메모리를 사용한다.

그런데 C의 자료형은 사실 이 ‘크기’로만 구분되며 C에서의 값은 어떤 타입을 가지지 않는다. 우리는 char 타입이 ‘글자 1개’를 저장하는데 사용되는 타입이라고 배웠지만, 사실 char 타입은 단지 1바이트메모리를 사용하는, 말하자면 한 칸짜리 저장공간일 뿐이다. 그럼 그 속에는 뭐가 저장될까? 비트에 저장할 수 있는 것은 0과 1 밖에 없다고 했고, 그것은 결국 2진수로 표현하는 숫자들인 것이다.

다음 코드는 char 타입의 변수 a를 선언하고 여기에 ‘A’ 라는 문자를 대입했다. 그런 다음 a + 1 을 출력하도록 한 코드이다.

// Example 001.c
#include <stdio.h>
int main(void) {
  char a = 'A';
  printf("%d\n", a + 1);
  return 0;
}

사과에 복숭아를 더할 수 없 듯이 문자와 정수를 더하는 연산은 뭔가 이상하지만, 이 코드 역시 정상적으로 잘 컴파일되고 실행도 된다. 그럼 무슨 일이 일어나고 있는 것일까? 변수 a에 저장되는 것은 실제로 글자 A가 아니다. 애초에 컴퓨터는 이진수만을 인식할 수 있다고 했는데, 그럼 글자를 숫자로 어떻게 표현할 수 있었지? 쉽게 생각한다면 공통으로 사용할 수 있는 글자표를 만들고 각 글자에 번호를 붙여두면 숫자로 글자를 가리킬 수 있을 것이다. 컴퓨터 역사 초창기에 미쿡쌀람들이 자기들이 많이 쓰는 글자와 숫자(문자로서의 숫자) 구두점 등의 기호들을 하나의 표로 만들어서 일종의 표준처럼 사용한 것이 있다. 그것은 지금도 널리 쓰이고 있는데, 바로 아스키코드(ASCII) 가 그것이다.

대문자 A는 아스키 코드에서 65번에 해당한다. 그래서 a에 저장되는 값은 숫자로 치면 65이고, 따라서 위 프로그램은 66을 출력하게 된다.

자료형과 메모리

메모리에 각 바이트에는 이처럼 숫자값이 들어가는데, 그러면 어떤 메모리에 들어있는 값이 한 바이트짜리 값인지 여러 바이트짜리 값인지는 어떻게 구분할 수 있을까? 메모리만 쳐다봐서는 당연히 구분할 수 있는 값이 없으므로 “구분할 수 없다”가 정답이다. 그리고 이걸 구분하기 위해서 변수의 타입을 이용한다.

C의 타입 시스템은 매우 단촐한데, 우선 값에는 타입이 없다. 그리고 변수는 타입이 있다. 요즘 프로그래밍 언어들은 직접적인 메모리 접근을 제한하는 경우가 많기 때문에 변수는 어떤 값에 대한 이름 혹은 참조에 해당한다. C에서는 모든 값은 메모리에 쓰여진 상태로 존재하고, 변수는 그 메모리 번지에 대한 이름이라 생각할 수 있다. 따라서 변수 이름을 통해서 값을 읽거나 쓸 때에는 그 값이 char인지 int인지에 따라서 접근하는 메모리의 범위가 달라지게 된다. 어떤 메모리 주소에 해당하는 한 바이트에 저장된 값은 어떤 타입으로써 읽느냐에 따라서 온전한 char 값 하나일 수도 있고, 어떤 float, int, double 값의 일부분일 수 있는 것이다.

  • 변수 a (char) → 메모리 주소 x 에 대해 그 1 바이트를 읽고 쓴다.
  • 변수 b (int) → 메모리 주소 y에 대해 y부터 y+1, y+2, y+3 의 4 바이트를 읽고 쓴다.

변수가 차지하는 메모리의 주소

변수를 메모리 내에 값을 담는 그릇에 비유하는 경우가 많다. 이런 비유를 사용하면서 한 가지 오해하기 쉬운 점은 우리는 어떤 값이 ‘변수’ 그 자체에 들어간다고 생각하게 된다는 점이다. 변수는 내부적으로 메모리 내의 어떤 위치를 특정해서 쓰는 것이며, 그것은 헷갈리거나 외우기 힘든 메모리 주소 대신에 가상의 이름을 사용하는 것과 같은 맥락이다.

하지만 C에서는 그 변수가 실제로 위치한 메모리의 주소값을 알아야 하는 경우가 있다. 이 때 사용하는 것이 &a와 같은 표현으로, 변수명 앞에 &를 쓰면 그 변수가 점유하고 있는 메모리의 주소값 자체가 된다. 아니, C의 자료형은 2바이트 이상을 쓰는 경우도 많으므로 정확하게는 변수가 점유하는 메모리 영역의 시작 바이트 주소가 되겠다.

개인적으로 포인터가 헷갈리기 시작하는 지점은 여기부터라고 생각된다. 데이터를 메모리에 쓰는 것은 알겠고, 변수 이름을 통해서 그 변수가 점유한 위치의 데이터를 읽고 쓰는 것도 알겠다. 이 과정에서 우리가 직접적으로 메모리 주소를 알 필요를 숨겨주는 것이다. 그리고 명시적으로 메모리 주소가 필요하다면 &a와 같이 번지값을 구할 수 있다. 그런데 왜 굳이 포인터가 필요한 것인가?

앞서 우리는 C의 모든 변수는 타입을 가지고 있다고 했고, C의 각 타입들은 값 하나가 사용하는 메모리의 양이 정해져있다고 했다. 메모리를 직접 액세스해야 하는 경우에 &a와 같은 주소 참조 표현이 아니라 포인터를 사용할 때의 차이는 무엇일까? 그것은 포인터는 결국 변수로 선언되며, 변수는 크기를 가지고 있다는 점 때문이다. 자 포인터는 메모리의 주소를 저장하기 위한 용도이므로 32비트 시스템에서는 32비트의 크기를 가질 것이고 64비트 시스템에서는 64비트(8바이트)의 크기를 가질 것이다. 하지만 포인터가 알아야 하는 자신의 크기는 이것이 아니다.

포인터 타입

포인터는 변수의 일종이므로, 선언한 후에 사용할 수 있다. C에는 pointer라는 별도의 타입이 존재하는 것이 아니라 기존 타입의 뒤에 * 를 붙여서 포인터를 표현한다. char * , int *, float * 등과 같이 말이다. 여기에는 굉장히 중요한 정보가 숨어 있는데, 어떤 타입 T 에 대해서 포인터를 선언하는 것은 T * 의 형태를 띈다. 이것은 포인터가 단일 타입이 아니라, 다른 원시 타입 T에 의존하고 있다는 것을 의미한다. (이를 유도형이라고 소개하는 교재들도 있다.) 즉 int * 와 char * 는 둘 다 고정크기의 포인터이지만, 하나는 int 타입에 대한 포인터, 다른 하나는 char 타입에 대한 포인터로 둘은 서로 다른 타입이다. 그리고 포인터가 의존하고 있는 유형은 포인터에서 매우 중요한 지점 중 하나이다.

배열과 포인터

이번에는 배열에 대해 생각해보자. 배열은 어떤 범용 프로그래밍 언어에서 가장 중요한 자료 구조 중 하나이다. C에서는 배열을 매우 성의없이 제공하는데, 특정한 크기 만큼의 데이터 공간을 연속적으로 할당해서 쓸 수 있게 해주는 것이다. 예를 들어 원소 4개짜리 int 형 배열이라면, int 1개가 4바이트를 사용하니, 16바이트를 할당해서 공간을 만들어주고 여기를 쓰라고 주는 것이다.

int arr[4] = {1, 2, 3, 4};

위와 같은 식이다. int 형 4개를 채울 수 있는 16바이트의 공간을 준비한 후, 순서대로 1, 2, 3, 4의 값을 채워서 초기화했다. 그러면 이 배열 변수의 이름인 arr 은 어떤 메모리 주소와 대응될까? 단일 int형 변수를 선언했을 때와 동일하다. 바로 16바이트 구간의 첫번째 주소값이 arr이 가리키게 되는 것이다. 여기서 알 수 있는 정보는 arr의 각 원소는 int 타입이라는 것과, arr 이라는 이름은 그 16바이트 중 첫번째 바이트의 주소를 가리킨다는 것. 그리고 이 배열은 정적으로 선언되었기 때문에 배열 arr의 크기가 4(원소가 4개)라는 것이다

그리고 배열의 각 원소를 포인터를 사용해서 액세스해보도록 하자. 여기에 int 타입 포인터인 ptr을 선언한다. 배열의 시작 번지는 &를 붙이지 않고 배열의 이름 그대로를 쓸 수 있다. 또한 포인터 내의 값을 읽거나 쓸 때에는 *ptr 이라고 쓸 수 있다. (int * ptr 이라고 선언했으므로 *ptr 이라고 쓰면 int 에 해당하기 때문에 이렇게 맞춘 것 같다…)

int * ptr = arr
// ptr 은 번지값 그 자체를 저장하는 변수
// 해당 번지에 있는 값을 읽거나 쓰려면
// 앞에 * 를 붙여서
// *ptr 이라고 쓴다.
printf("%d\n", *ptr);
// → 1 출력

집중하자. ptr 은 가리키는 정보가 뭐였든 실제 저장하고 있는 값은 메모리 시작 번지의 주소이다. 그러다가 *ptr 이라고 사용하는 순간, 해당 메모리 주소로부터 값을 읽어와야 한다. 이 때 몇 바이트나 읽어야 제대로된 값을 읽을 수 있을까? 이걸 알려면 ptr 자체의 크기보다는 ptr이 가리키는 원형의 타입을 알아야 한다. ptr은 int 형 포인터이므로 4바이트를 읽게 되는 것이다.

포인터의 흥미로운 점은 정수와 덧셈, 뺄셈을 할 수 있다는 것이다. 포인터 변수에 +1 을 하면 포인터가 가리키는 타입의 크기만큼 메모리 주소가 더해진다. 예를 들어 위 예에서 ptr이 0x61FE00 이라는 위치를 가리키고 있다면 ptr + 1은 0x61FE01 이 아닌 0x61FE04가 된다. 그럼 이런 식으로 포인터를 써서 배열의 값을 출력해보도록 하자.

#include <stdio.h>
int main(void) {
  int arr[4] = {1,2,3,4};
  int * ptr = arr;
  printf("%d@%lx\n", *ptr, ptr);
  ptr += 1;
  printf("%d@%lx\n", *ptr, ptr);
  ptr += 1;
  printf("%d@%lx\n", *ptr, ptr);
  ptr += 1;
  printf("%d@%lx\n", *ptr, ptr);
  return 0;
}

이와 같이 T 타입 포인터 ptr이 T타입 배열의 시작번지를 가리키고 있다면 i 번째 요소의 값은 *(ptr + i)로 액세스할 수 있다. 배열의 이름이 배열의 시작 번지를 가리킨다고 했기 때문에 arr[i] 도 본질적으로는 *(arr + i)와 동일하다. 덧셈의 순서는 변경해도 값이 바뀌지 않으므로 *(i + arr)로 쓸 수 있고 이것을 다시 i[arr]로도 쓸 수 있는 것이다. 이제 첫번째 이상한 코드가 어떻게 작동할 수 있었는지 감이 좀 오시는지?

그럼에도 불구하고 여기서 arrint * 타입이 아니다. 배열과 달리 포인터는 연속된 T타입 값이 몇 개 있는지에 대한 정보를 가지지 않는다. 하지만 배열은 정적으로 선언된 경우, 그 크기에 대한 정보를 알고 있게 된다. 이는 ptr 과 arr에 대해서 sizeof 연산자를 적용해보면 알 수 있다.

printf("arr: %d\nptr: %d\n", sizeof(arr), sizeof(ptr));
//  → 16 / 8
// arr[4]은 int * 4 이므로 16
// ptr은 64비트 시스템에서 컴파일했으므로 8

포인터의 사용법

포인터를 배열에 대해서 어떻게 사용하는 것인지를 살펴보았는데, 사실 배열은 arr[i] 리터럴로도 충분히 사용할 수 있기 때문에 굳이 포인터를 사용해야 하는가 하는 궁금증이 생긴다. 그러면 다른 곳에 포인터를 사용할 수 있을까?

포인터의 핵심 개념은 어떤 데이터 혹은 변수가 위치한 메모리의 주소를 알아내어 그 주소를 변수에 담아 둔다는 것이다. 이렇게 변수에 담아둔 값은 ‘이리저리 주고 받으며’ 사용할 수 있다. 그리고 어떤 위치에 대한 메모리 주소와 타입을 알고 있다는 것은 그 데이터를 읽고 쓸 수 있게 된다는 말과 같은 의미이다.

함수의 인자로서의 포인터

가장 먼저 떠오르는 것은 함수에서의 사용이다. 함수 내부에서는 기본적으로 함수의 지역 변수에 대한 액세스가 가능하다. 함수의 지역 변수는 함수 본체 내에서 선언한 변수들과 함수가 받는 파라미터들이 여기에 속한다. 그런데 함수의 지역 변수들은 함수가 호출되는 시점에 스택 영역에 생성되는 변수들이다. 즉 함수가 자기 자신이 알고 있는 변수들만 액세스할 수 있다면, 함수 외부에 있는 값을 바꿀 수 있는 방법은 없다는 것이다.

예를 들어 두 개의 int 값을 서로 맞바꾸는 동작을 생각해보자. 변수 a, b가 있다면 추가적인 변수 c를 사용하여 다음과 같이 스와핑할 수 있다.

int a, b, c;
a = 2; b = 3;
c = a; a = b; b = c;

그런데 이 동작을 함수로 작성하고 싶다면 어떻게 해야할까? ‘서로 값이 바뀐 조합’을 리턴하는 것도 방법이겠지만, C 에서의 함수는 한 번에 하나의 값만을 리턴할 수 있으며, C에는 튜플과 같은 자료 구조가 기본적으로 지원되지 않는다. 따라서 우리가 작성하고자하는 함수는 외부의 값을 변경할 수 있는 능력이 있어야 한다. 이것을 가능하게 해주는 것이 포인터이다. int 값이 아닌 int 포인터 두 개를 인자로 받고, 위와 같은 로직으로 두 포인터 위치에 대해 값을 바꾸는 동작을 수행하도록 함수를 다음과 같이 작성할 수 있다.

void swapInts(int * a, int * b) {
  int c = *a;
  *a = *b;
  *b = c;
}

이 함수를 호출하기 위해서는 두 int 변수의 값을 그대로 넘기는 것이 아니라 두 변수의 주소값을 넘겨주어 호출하면 된다.

int main(void) {
  int a, b;
  a = 2; b = 3;
  swap(&a, &b);
  printf("a:%d, b:%d\n", a, b);
}

즉 포인터는 함수에서 외부의 값을 변환할 때 사용된다. 이는 별도의 리턴을 받지 않으면서 어떤 객체를 함수에 전달하고 함수 내부에서 변경하도록 하는 형태의 API를 만드는데 도움을 준다. 실제로 많은 C API들이 이런식으로 작성되어 T타입 포인터 (혹은 이중 포인터)를 인자로 받고, 단순히 종료 코드값을 int 형으로 리턴하는 식으로 작성된다.

참고로 함수가 T타입의 배열을 인자로 받도록 선언된 경우, 실질적으로 해당 인자는 함수 내부에서 배열이 아닌 T타입 포인터로 생성된다. 다음 코드에서 확인해볼 수 있다. 아래 함수 inspect에서 인자는 int [] 배열타입으로 선언했지만, 내부에서는 int * 타입으로 동작하는 것을 확인할 수 있다.

#include <stdio.h>
void inspect(int arr[]) {
  printf("%d\n", sizeof(arr));
}
int main() {
  int arr[4] = {1, 2, 3, 4};
  inspect(arr);
  return 0;
}
// → 8 :: 16이 아니라 int * 의 사이즈를 출력한다.

하지만 함수 inspect 내에서 i 번째 요소를 액세스하려할 때에는 arr[i]로 쓰는 것은 무방하다. 이것은 컴파일러에 의해서 *(arr + i) 로 변경될 것이기 때문이다. 통상 함수를 인자로 받아서 무언가를 하려는 함수는 반드시 배열의 크기(요소의 개수)를 함께 인자로 받아야 한다. C는 배열을 위해 메모리를 할당해줄 뿐 그 어떤 바운더리 체크등의 기능을 지원하지 않기 때문이다. (C가 빠른 건 컴파일 언어라서가 아니라 이런 기능들을 다 빼서 그만큼 오버헤드가 없기 때문이다.)

void printArray(int * arr, int size) {
  int i;
  for(i=0; i<size; i++) {
    printf("%d: %d\n", i, arr[i]);
  }
}

함수의 인자로 어떤 값의 포인터를 넘기는 것은 성능 측면에서 매우 유리하다. 함수가 실행될 때에 스택 영역에 지역변수들이 생성되고 인자로 넘겨진 데이터들이 복사되는데, 만약 엄청 큰 덩어리의 구조체를 그대로 인자로 넘긴다면 데이터 복사에 소요되는 비용이 그만큼 들어갈 것이다. 하지만 포인터를 넘겨준다면 단지 4~8바이트 짜리 데이터가 넘겨지고, 똑같은 일을 수행할 수 있게 된다.

메모리 동적 할당

또한 메모리를 동적으로 할당할 때에도 포인터는 필수적이다. 실행 전에 배열을 선언하는 경우에는 int arr[4]; 와 같이 크기를 지정하거나, 크기를 생략하는 경우에는 정적으로 초기화해야 한다. (int arr[] = {1, 2, 3, 4};) 이 때 배열의 크기를 미리 알 수 없다면 어떻게 할까? 정적으로 충분히 큰 배열을 미리 만들어 둘 수 있는데, 이렇게하면 메모리를 낭비하게 되거나, 혹은 예상보다 큰 입력을 받는 경우에 문제가 발생할 수 있다.

다음 코드는 정수 N을 입력받고 다시 N개만큼 정수를 더 입력 받은 후 입력받은 수 중에서 홀수들만 합산하여 출력하는 프로그램이다. (물론 배열 없이 입력 받을 때마다 체크하여 합산할 수도 있겠지만…)

#include <stdio.h>
#include <stdlib.h>
int main(void) {
  int i, n, s = 0;
  int * arr;
  scanf("%d", &n);
  // 입력받은 크기만큼 배열을 생성
  arr = (int *)malloc(sizeof(int) * n);
  for(i=0;i<n;i++) {
    scanf("%d", arr + i);
  }
  for(i=0;i<n;i++) {
    if(i%2==1) {
      s += arr[i] * 2;
    }
  }
  printf("sum: %d\n", s);
  free(arr);
  return 0;
}

그외의 경우들

그 외에도 모든 포인터는 같은 플랫폼에서 사이즈가 같고 따라서 (안전하지는 않지만) void * 형 포인터로 상호 변환이 가능하다는 점을 활용하여 직접 설계한 구조체에 대한 조작 함수를 포인터를 받도록 작성한 후, 실제 API 상에서의 데이터 타입을 void * 로만 노출하여 라이브러리를 작성할 때 데이터 타입의 내부구조를 감추는 것도 가능하다.

이상으로 C의 포인터를 이해하는데 필요한 이야기들을 정리해보았다. ‘메모리 번지를 저장해놓고 가리킨다’는 단순한 내용은 사실 메모리를 어떻게 액세스하고 있고, 변수의 타입은 여기서 어떤 역할을 하는가에 대한 이해를 필요로 하고 있음을 이야기했다. 부디 이 글이 C를 공부하는데 있어서 도움이 되었으면 좋겠다.