SturdyCobble's Study Note

[프로그래밍] 6.5 포인터와 배열 (2) 본문

휴지통/['19.06-'20.07]프로그래밍101

[프로그래밍] 6.5 포인터와 배열 (2)

StudyingCobble 2020. 2. 23. 22:30

NOTICE : 독학인 만큼, 잘못된 내용이 있을 수 있습니다. 내용 지적은 언제나 환영합니다.

더 이상 이 블로그에는 글을 올리지는 않을 예정입니다. 그렇지만 댓글을 달아주시면 최대한 답변드리고자 노력하겠습니다.


※이 글은 프로그래밍 언어에 대한 기초적인 이해를 가정하고 있습니다. 최소 프로그래밍 언어 하나 정도를 약간이라도 접해보시는 것을 추천합니다. 또한, 이 글은 심화 내용은 되도록 피하여 서술했습니다.

 

 이번 글에서는 포인터를 간단하게 활용하고, 연산하는 방법을 알아보겠습니다.

 

 포인터는 기본적으로 주소이므로 숫자를 저장하고 있습니다. 따라서 그냥 덧셈, 뺄셈 다 될 것 같지만, 그렇지 않습니다. 실수로 잘못된 주소를 건드리는 경우가 있을 수도 있기에 주소를 주소로 곱하는 일은 허용되지 않습니다.

비슷한 이유로 사칙연산 중에서 포인터와 숫자 사이 덧셈과 뺄셈, 포인터와 포인터 사이 뺄셈만이 가능합니다. 각 연산의 결과는 다음과 같은 그림으로 표현할 수 있습니다. (물론 포인터 사이에는 타입이 맞아야 합니다.)

 

보다시피 빼는 경우 둘 사이의 주소상 거리를 나타내는 것이 아니라, 배열에서 거리처럼 T형 포인터는 sizeof(T)를 단위로 하여 측정된 주소상의 거리를 나타내게 됩니다. 포인터와 정수의 덧셈 또한 비슷한 규칙이 적용됩니다. 이 때 유의할 점은 포인터 사이 뺄셈의 결과는 포인터가 아니라 정수라는 점입니다.

 

 

 만약, 평균이나 3등분점 같은 지점을 찾아야 하는 경우는 어떻게 해야 할까요? 가장 먼저 다음과 같은 코드를 떠올릴 수 있을 것입니다.

int *ptr1 = &num1;
int *ptr2 = &num2;

int *ptrm2 = (ptr1+ptr2)/2
int *ptrm3 = (ptr1+2*ptr2)/3

하지만, 이 경우 포인터와 정수의 나눗셈(또는 곱셈)을 계산해야 해서 문제를 해결할 수 없습니다. 이 경우 정수와 정수의 나눗셈, 정수와 포인터의 덧셈으로 나누어서 생각해볼 수 있습니다.

 

int *ptr1 = &num1;
int *ptr2 = &num2;

int *ptrm2 = ptr1+(ptr2-ptr1)/2
int *ptrm3 = ptr2+(ptr1-ptr2)/3

포인터의 뺄셈은 정수형의 값을 반환하고, 이를 나누어도 (버림을 한) 정수형 값을 반환하므로 최종적으로 정수와 포인터 사이 덧셈을 수행하게 됩니다.

 

 배열은 포인터와 같이 자주 활용됩니다. 이는 배열의 [ ]의 의미가 단순한 인덱싱 이상으로 활용될 수 있기 때문입니다. 또한, 배열 자체는 다른 언어에서 지원하는 리스트와 같은 자료형과는 다르게 매우 간단해서(때로는 불편하지만) 포인터로서 그 값을 가져올 수도 있습니다. 아래 예시를 참고해봅시다.

int arr[5] = {1,2,3,4,5};
int* ptr = arr;
printf("%d",*ptr);
printf("%d",*(ptr+1));
printf("%d",*(ptr+2));


------------실행 결과----------
123

 

포인터를 통해 배열의 각 요소에 접근할 수 있음을 확인할 수 있습니다. 여기서 한가지 눈에 띄는 점은 &arr이 아니라 arr을 썼다는 점일 것인데, 이는 배열의 이름 자체가 첫번째 요소의 주소를 저장하기 때문입니다. char* 로 저장한 문자열도, 이와 같은 맥락에서 이해될 수 있습니다.

  char* str = "KOREA";
  char* ptr = str;
  printf("%c",*str);
  printf("%c",str[0]);
  
  ------------실행 결과---------
  KK

여기서 문자열을 저장하기 위해 포인터를 사용한 것은, 저 문자열 자체가 일종의 주소를 나타낸 다는 의미일 것입니다. 실제로, 우리가 사용한 문자열은 특정한 메모리 상 주소에 따로 저장되어 있습니다. 따라서 문자열이 저장된 위치를 가르킨다고 이해할 수 있습니다.

 

조금만 배열을 활용하면 다음과 같은 예제도 가능합니다.

  int arr[] = {1,2,1024,4096};
  int* ptr = (int*)arr; //나중에 바꾸기 위해서 일부러 불필요한 캐스팅을 남겨두었습니다.
  for(int i = 0; i < sizeof(arr)/sizeof(int); i++){
    printf("%d ",*(ptr+i));
    printf("%d\n",ptr[i]);
  }
  
-----------RESULT--------------
1 1
2 2
1024 1024
4096 4096

포인터와 배열을 같이 사용한 예시입니다. 한번 더 바꾸면 다음같은 예시도 가능합니다.

 

int arr[] = {1,2,1024,80960};
unsigned short* ptr = (unsigned short*)arr;
for(int i = 0; i < sizeof(arr)/sizeof(short); i++){
    printf("%d ",*(ptr+i));
    printf("%d\n",ptr[i]);
}
------------------RESULT------------------
1 1
0 0
2 2
0 0
1024 1024
0 0
15424 15424
1 1

 

이 결과는 다음과 같이 값이 저장되어 있음을 암시합니다.

높은 주소부터 숫자가 먼저 채워진다는 점을 확인할 수 있습니다. 좀 더 이해를 돕기 위해 1024와 80960의 케이스를 더욱 확대해보겠습니다.

 

이렇게 방향을 반대로 생각하면 더 간편하게 이해할 수 있습니다. 이렇게 작은 단위가 주소가 낮은 쪽에 위치하는 방식을 '리틀 엔디안(Little-endian)'이라고 부릅니다. 이 방식은 우리가 읽는 방식과 반대되지만, 작은 단위쪽을 더 쉽게 접근할 수 있습니다. (배열을 생각하면, 배열명이 시작 주소이므로 별도의 덧셈 없이 바로 가장 작은 단위쪽의 수를 가져올 수 있습니다.)

 

반대로, 큰 단위가 주소가 낮은 쪽에 위치하는 방식인 '빅 엔디안(Big-endian)' 방식은 직관적으로 이해하기가 쉽습니다. (우리가 왼쪽에서 오른쪽으로 글자를 쓰고, 숫자를 적을 때 큰 단위(1234에서는 천 단위부터) 시작하기 때문일 것입니다.) 이러한 방식은 CPU에 따라 달라지며, 인텔 계열의 경우 리틀 엔디안을 사용한다고 합니다.

 

 

 배열에서 하나씩 값을 불러오는 경우를 다음과 같은 코드를 사용할 수 있습니다.

  int arr[] = {0,0,12,2,35,1,22,1,1,42,91,1,15,2};
  //METHOD 1
  int* ptr = arr;
  for(int i = 0; i < sizeof(arr)/sizeof(int); i++){
    printf("%d ",*(ptr+i));
  }
  printf("\n");

  //METHOD 2
  ptr = arr;
  for(int i = 0; i < sizeof(arr)/sizeof(int); i++){
    printf("%d ",*ptr);
    ptr++;
  }
  printf("\n");


  //METHOD 3
  ptr = arr;
  for(int i = 0; i < sizeof(arr)/sizeof(int); i++){
    printf("%d ",*ptr++);
  }
  printf("\n");


  //METHOD 4
  ptr = arr;
  for(int i = 0; i < sizeof(arr)/sizeof(int); i++){
    printf("%d ",*(ptr++));
  }

 

위와 같이 4가지 방법을 생각할 수 있습니다. 첫 번째는 이미 다루었었고, 두 번째는 포인터의 연산을 이용한 것입니다. 그러나, 두 번째 방법을 ++을 이용하여 세 번째 방법으로 확장할 수 있습니다. 이 때 둘의 연산 순서가 같지만, 오른쪽이 우선되어 덧셈을 하고 포인터 값을 가져올 것같지만, 후위 연산자이기 때문에 현재 포인터가 가르키고 있는 값을 가르키고, 포인터에 1을 더하게 됩니다.  (4번도 동일합니다.)

 

만약, 종료를 나타내는 값이 따로 있다면, 코드는 더 간결해질 수 있습니다. 

int arr[] = {0,0,12,2,35,1,22,1,1,42,91,1,15,2,-1};
int* ptr = arr;
while(*ptr != -1) printf("%d ",*ptr++);

이 경우 -1을 종료를 나타내는 값으로 사용했고, 굳이 괄호를 쓸 필요없이 위와 같이 간단한 코드를 작성할 수 있습니다.

 

 포인터 변수에는 정수를 직접 대입하는 것도 불가능합니다. 번지를 굳이 직접 언급할 일도 거의 없을 뿐더러 오류를 일으킬 가능성도 있기 때문입니다. Error: invalid conversion from 'int' to 'int*'과 같이 묵시적인 변환을 할 수 없다는 에러가 뜰 것입니다. 물론 캐스팅 연산자를 이용해 변환하여 대입할 수는 있습니다.

 

 이 예외가 되는 값이 있는데 0입니다. 포인터에 0을 대입할 수도 있고, 0이랑 포인터를 비교할 수도 있습니다.

 

이는 0이란 주소가 특별한 의미를 가지고 있어서가 아니라, 일반적으로 접근할 수 없는 주소이기 때문입니다. 포인터를 반환해야 하는 함수라면 0을 반환할 수도 있습니다. 에러를 처리하기 위해(비교의 경우라면 이러한 함수에 대해서 에러가 있는지 판단하기 위해) 사용될 수 있습니다. 특정 주소를 찾는 경우 주소를 찾을 수 없을 때, 0을 반환하는 것을 고려해볼 수 있습니다.

 

 다만, 0이 무슨 의미를 나타내는지 알아차리기 쉽지 않을 수 있어 NULL이라는 상수를 대신 이용합니다. (만약, 0번째 주소가 의미가 있어지더라도 NULL 상수 값만 바꾸면 오류를 막을 수 있기 때문도 있을 것입니다. )

 

int* whereIs12(int* arr_start) {
  int* p = arr_start;
    while(*p != -1) {
      if (*p == 12) return p;
      p++;
    }
    return NULL;
}

int main(){
  int arr[] = {0,0,2,2,35,1,22,1,1,42,91,1,15,2,-1};
  if (whereIs12(arr) == NULL) { printf("NO SUCH ELEMENT"); }
  else { printf("%d",*whereIs12(arr)); }
}

위 예시는 12의 위치를 찾는 함수입니다. 저렇게 복잡한 함수를 쓸 이유는 없겠지만, 아무튼 저런 식의 함수가 있을 때, 값을 찾지 못한 경우 NULL을 반환하게 해볼 수 있습니다. 

 

물론, 종료를 나타내는 -1을 사용하지 않고, 아래와 같이 배열을 함수에 전달하는 경우도 가능할 것입니다.

int* whereIs12(int* m_arr) {
  int* p = m_arr;
  for(int i  = 0; i < sizeof(m_arr); i++) {
      if (*p == 12) return p;
      p++;
  }
  return NULL;
}

int main(){
  int arr[] = {0,0,2,12,35,1,22,1,1,42,91,1,15,2};
  if (whereIs12(arr) == NULL) { printf("NO SUCH ELEMENT"); }
  else { printf("%d",*whereIs12(arr)); }
}

 

 

 

 

한편, 포인터의 포인터나 void의 포인터와 같은 상황도 가능합니다. void포인터의 경우 char*처럼 1바이트 단위로 주소를 가져올 때 사용됩니다. (당연하지만, 명시적 형 변환이 없으면 *ptr로 값을 가져올 수 없습니다.) 이중 포인터의 경우 다른 글에서 다시 다루도록 하겠습니다.

 

 

 

Comments