정글사관학교 개발일지/자료구조&알고리즘

정글사관학교 33일차 TIL: C언어 기초 - 포인터, 함수

Woonys 2021. 12. 4. 23:52
반응형

포인터의 덧셈/뺄셈

 

포인터에 1을 더하면 해당 포인터의 변수형 크기만큼 더해진다. 예컨대 pa라는 포인터가 int 형을 가리킨다고 하자. int 형의 바이트 크기는 4이므로 포인터 값에 +4만큼 더해진다. char형 변수를 가리키는 포인터라면 char의 크기가 1바이트이니 +1만큼 더해진다.

 

<포인터에 정수를 더하면 해당 포인터가 가리키는 형의 크기만큼 주소값에 더해진다.>

 

뺄샘 역시 마찬가지로 수행 가능하다. 덧셈과 뺄셈은 본질적으로 같은 개념이니 어느 정도 예상 가능. 다만, 이 역시도 해당 포인터가 가리키는 형의 크기만큼 주소값에서 빼진다.

 

그러면 두 포인터끼리 더할 수는 있을까? 놉. 포인터끼리의 덧셈은 의미가 없다. 두 변수의 메모리 주소값을 더하면 새로운 메모리의 주소값이 나올텐데 그 주소값과 기존에 더하려 했던 두 주소값 사이에는 아무런 연관이 없기 때문. 즉, 어떤 포인터에 정수를 더하는 건 허용되나 다른 포인터 값을 더하는 건 의미가 없어서 처리되지 않는다.

 

배열과 포인터

 

왜 포인터의 연산은 위와 같이 이뤄질까? 즉, 포인터에 정수는 더하거나 뺄 수 있는데 포인터끼리는 안되고, 포인터에 정수를 더하거나 빼는 것도 포인터가 가리키는 형의 크기만큼 이뤄지는 걸까? 이를 알기 위해서는 배열과 포인터 사이 연관성을 알 필요가 있다

 

아래와 같은 배열을 만든다고 해보자.

 

int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

이때, arr 배열에 들어있는 각 원소는 메모리 상에서 연속되게 놓인다. 위의 배열은 메모리 상에서 아래와 같이 나타난다.

 

 

\

int형 변수는 한 원소의 크기가 4바이트니까  아래처럼 arr[0]은 총 4칸을 차지하고, 이러한 arr[i]가 총 10칸에 늘어서 있다. 이때, 각 배열 원소의 주소값을 불러오면 어떨까?

 

/* 배열의존재상태? */ 
#include <stdio.h> 
int main() {
  int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; 
  int i;
  
  for (i = 0; i < 10; i++) {
    printf("arr[%d] 의 주소값 : %p \n", i, &arr[i]);
  }
  return 0; 
}

이를 컴파일하면 아래와 같이 나온다.

arr[0] 의 주소값 : 0x7ffeb5683890
arr[1] 의 주소값 : 0x7ffeb5683894
arr[2] 의 주소값 : 0x7ffeb5683898
arr[3] 의 주소값 : 0x7ffeb568389c
arr[4] 의 주소값 : 0x7ffeb56838a0
arr[5] 의 주소값 : 0x7ffeb56838a4
arr[6] 의 주소값 : 0x7ffeb56838a8
arr[7] 의 주소값 : 0x7ffeb56838ac
arr[8] 의 주소값 : 0x7ffeb56838b0
arr[9] 의 주소값 : 0x7ffeb56838b4

 

보면 주소값이 4씩 증가하는 것을 알 수 있다. 즉, 우리는 포인터에 정수를 더해 배열의 원소를 불러올 수 있다. 포인터가 가리키는 건 해당 데이터의 시작 주소값이니, 예컨대 arr[0]을 가리키는 포인터가 pa라고 할때, 여기에 1을 더하는 건 시작 주소값에 4를 더하는 것과 같고 이는 arr[1]의 시작 주소값에 해당한다.

 

이를 통해 *(포인터 + 정수) 값으로 해당 배열의 원소값을 불러올 수 있다. 예컨대

 

/* 우왕*/
#include <stdio.h> 
int main() {
    int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; 
    int* parr;
    parr = &arr[0];
    printf("arr[3] = %d , *(parr + 3) = %d \n", arr[3], *(parr + 3));
    return 0; 
}

 

위 코드에 대한 실행 결과는

arr[3] = 4
*(parr + 3) = 4

로, arr[i] == *(parr+i)가 된다.

 

배열 이름의 비밀

 

배열 이름 변수를 16진수로 출력하면 배열의 0번째 값이 배정되어 있는 주소값과 동일한 값을 출력한다.

 

#include <stdio.h> 

int main() {
    int arr[3] = {1, 2, 3}; 
    printf("arr 의 정체 : %p \n", arr);
    printf("arr[0] 의 주소값 : %p \n", &arr[0]); 
    return 0;
}

 

arr 의 정체 : 0x7fff1e868b1c
arr[0] 의 주소값 : 0x7fff1e868b1c

즉, 배열에서 배열의 이름은 배열 첫 번째 원소의 주소값을 나타내고 있는 것과 같다. 여기서 문제! 배열의 이름이 배열의 첫 번째 원소를 가리키는 포인터라고 할 수 있을까? 그렇지 않다. 

 

배열은 배열이고 포인터는 포인터이다. 엄밀히, 배열 이름과 첫번째 원소의 주소값은 다르다. 하지만 암묵적으로 배열의 이름을 16진수로 출력하면 해당 배열의 첫번째 원소를 가리키는 포인터로 타입을 변환해준다. 따라서 sizeof와 주소값 연산자와 함께 사용할 때를 제외하면, 배열 이름은 첫 번째 원소를 가리킨다.

 

또한, arr[i]와 같은 문장은 연산자[]에 의해 *(arr+i)로 변환된다. 앞에서 배열 이름을 시작 주소값으로 정하는 이유는 바로 이것. []는 연산자이다! 따라서 arr을 배열의 시작 주소값으로 하고 i칸 뒤에 위치한 원소 값을 불러온다. []는 연산자이므로 i[arr] 역시 같은 값을 나타낸다. (==*(i+arr))

 

1차원 배열 가리키기

 

다른 *int 포인터가 배열을 가리키게 하려면?

 

#include <stdio.h> 
int main() {
    int arr[3] = {1, 2, 3}; 
    int *parr;
    parr = arr;
    /* parr = &arr[0]; 도 동일하다! */
    printf("arr[1] : %d \n", arr[1]); 
    printf("parr[1] : %d \n", parr[1]); 
    return 0;
}

 

위에서 주목해야 할 부분은

 

parr = arr;

arr을 parr에 대입한다. 이미 parr을 포인터 변수로 선언했기 때문에, 여기서 arr은 배열의 첫 번째 원소를 가리키는 포인터로 타입이 변환되고 (parr이 int 포인터 타입이니) 그 원소의 타입이 int이므로 포인터의 타입은 int*가 된다. 위의 문장은 아래와 정확히 똑같다.

 

parr = &arr[0];

이는 아래 그림과 동일.

 

포인터의 포인터

 

포인터를 가리키는 포인터 변수는 어떻게 선언할까?

 

int **p;

 

설마 했는데 진짜다;;;

 

아래 예제를 살펴보자.

 

/* 포인터의 포인터 */ 
#include <stdio.h> 
int main() {
  int a;
  int *pa; 
  int **ppa;
  
  pa = &a;
  ppa = &pa;

  a = 3;
  
  printf("a : %d // *pa : %d // **ppa : %d \n", a, *pa, **ppa); 
  printf("&a : %p // pa : %p // *ppa : %p \n", &a, pa, *ppa); 
  printf("&pa : %p // ppa : %p \n", &pa, ppa);
  
  return 0; 
}

 

이것을 컴파일하면 같은 줄에 같은 값이 나온다.

 

pa는 a의 주소값을 가리키는 포인터이다.

ppa는 포인터 pa의 주소값을 가리키는 이중 포인터이다.

 

그림으로 보면 이해가 쉽다. 포인터 역시 변수이니 메모리의 일부분을 차지하며 고유의 메모리 주소가 있다는 점을 짚고 가자.

 

그밖에도 이차원 배열에 대한 포인터 등 아직 깊이 봐야할 포인터 내용이 산더미같이 쌓여있으나..일단 포인터 개념은 이쯤에서 정리하고 필요한 개념이 있다면 그때그때 보고 정리할 계획.

 

 

Ch.11 함수

 

함수 개념은 잘 알고 있으니 바로 예제로 넘어가기.

 

#include <stdio.h>
/* 보통C 언어에서, 좋은함수의 이름은 그 함수가 무슨 작업을 하는지 명확히 하는 것이다. 수학에서는
f(x), g(x) 로 막 정하지만, C 언어에서는 그 함수가 하는 작업을 설명해주는 이름을 정하는 것이 좋다. */

int print_hello() {
  printf("Hello!! \n");
  return 0; 
}

int main() {
  printf("함수를 불러보자 : "); 
  print_hello();
  printf("또 부를까? "); 
  print_hello(); 
  return 0;
}

아래 main 함수는 위의 print_hello 함수를 불러온다. 이때, 주의할 점은 함수 앞에 int가 붙는다는 것인데, int는 변수나 배열을 정의하는 데만 쓰는 게 아니라 함수를 정의할 때도 쓴다. 여기서의 int가 의미하는 바는 "이 함수는 int 형의 정보를 반환한다"는 의미를 지니고 있다.

 

위에서 print_hello는 0을 반환한다. 이 함수의 반환형이 int이니, 0은 int 형태로 저장된다. 여기서 int 형태로 저장된다는 말은 0이라는 데이터가 메모리 상의 4바이트를 차지하여 반환된다는 말이다.

 

C언어 함수에서 주의할 점은, 함수의 이름이 20자가 넘어가지 않게 해야 한다는 점이다.

 

 

main 함수

 

int main() {} 역시 함수. 왜 하필 main이라고 쓰는 걸까? 이유는 프로그램을 실행할 때 컴퓨터가 main 함수부터 찾기 때문이다. (적어도 우리가 앞으로 만들게 될 C 프로그램들의 경우는 그러함). 즉, 컴퓨터는 프로그램을 실행할 때 프로그램의 main 함수를 호출함으로써 시작한다. 만약 main 함수가 없다면 컴퓨터는 프로그램의 어디서부터 실행할 지 모르게 되어 오류가 난다.

 

여기서 질문. 메인 함수가 return하는 값은 누가 받을까? 가장 처음에 실행되는 함수가 main이니 마지막으로 종료되는 함수 역시 메인이라 리턴 값을 받을 수 없지 않나? 놉. 메인 함수가 리턴하는 데이터는 운영체제가 받아들인다. 메인 함수가 정상적으로 종료되면 0을 리턴하고 비정상적으로 종료되면 1을 리턴한다고 규정되어 있다. 우리가 여태까지 만들었던 모든 메인 함수들은 정상적으로 종료되므로 마지막에 0을 리턴한다.

 

함수의 인자

 

우리가 지금까지 썼던 함수들은 인자를 받지 않는 함수였다. 지금부터는 인자를 받는 함수를 써보자.

 

/* 마술상자*/ 
#include <stdio.h> 

int magicbox() {
  i += 4;
  return 0; 
}

int main() {
  int i;
  printf("마술 상자에 집어넣을 값 : "); 
  scanf("%d", &i);
  magicbox();
  printf("마술 상자를 지나면 : %d \n", i); 
  return 0;
}

 

위 함수는 에러가 뜰 것이다. int i는 매직박스 함수에서 정의된 지역 변수이기 때문에 main에서는 int i에 대한 정보가 없다. 따라서 우리는 인자를 받아서 그에 대해 더해주는 함수를 만드는 식으로 수정해야 한다.

 

#include <stdio.h>

int slave(int master_money) {
   master_money += 10000;
  return master_money; }

int main() {
  int my_money = 100000;
  printf("2009.12.12 재산 : $%d \n", slave(my_money));
  return 0; 
}

 

이를 실행하면 100000에 10000을 더해 110000이 출력된다. 여기서 함수의 정의 부분에서 int master_money가 쓰여져 있는 것을 볼 수 있다. 이것이 인자.

 

포인터로 받는 인자

 

앞에서 포인터를 배우면서 이것을 어따 써먹으면 좋을지에 대해 도저히 이해할 수 없었다. 그런데 이제 그 실마리가 하나 밝혀지는...!

 

이제까지 배웠던 포인터를 다시 리뷰부터 해보자.

 

1. 포인터는 특정한 변수의 메모리 상 주소값을 저장하는 변수다.
2. int 형 변수의 주소값을 저장하면 int*, char이면 char* 형태로 선언된다.
3. 또한 * 단항 연산자를 이용하여, 자신이 가리키는 변수를 지칭할 수 있으며
4. & 연산자를 이용하여 특정한 변수의 주소값을 알아낼 수 있다.

이 포인터를 이용하면 함수 간의 변수를 연결해서 바꿀 수 있다. 여러 함수 간에 연결을 할 때 문제는, 각 함수는 다른 함수의 변수들에 대해 아는 것이 아무 것도 없다는 것이다. 인자를 이용해서 다른 함수에 정의된 변수들의 값을 전달하는 것이 가능하나 여전히 불가능한 지점들이 있다. 이를 포인터를 이용하면 각 지역 변수를 조작할 수 있게 되는데

 

/* 드디어 써먹는 포인터 */ 
#include <stdio.h>
int change_val(int *pi) { //pi는 change_val에서 정의된 포인터
  printf("----- chage_val 함수 안에서 -----\n"); 
  printf("pi 의 값 : %p \n", pi);
  printf("pi 가 가리키는 것의 값 : %d \n", *pi);
  *pi = 3;
  printf("----- change_val 함수 끝~~ -----\n");
  return 0; 
}


int main() { //본 함수부터 실행
  int i = 0;
  
  printf("i 변수의 주소값 : %p \n", &i); 
  printf("호출 이전 i 의 값 : %d \n", i); 
  
  change_val(&i);
  
  printf("호출 이후 i 의 값 : %d \n", i);
  
  return 0; 
}

위를 실행하면 아래와 같이 출력된다.

i 변수의 주소값 : 0x7ffd3928afc4
호출 이전 i 의 값: 0
----- chage_val 함수 안에서 -----
pi의 값 : 0x7ffd3928afc4
pi 가 가리키는 것의 값: 0
----- change_val 함수 끝~~ -----
호출 이후 i 의 값: 3

보면 호출 이전과 이후에 대해 i의 값이 바뀐 것을 볼 수 있다! 포인터를 이용하니 원래같았으면 건드리지 못했을 다른 함수 내 지역 변수의 값을 건드릴 수 있게 된 것이다..! 아래 그림을 보면 좀 더 명확히 이해가 가능하다.

 

 

두 변수의 값을 교환하는 함수

 

아래 코드는 두 인자 a, b의 값을 서로 바꾸는 함수 swap과 이를 main 안에서 실행하는 것에 대한 내용이다. 실행하면 어떻게 될까?

#include <stdio.h>
int swap(int a, int b) {
  int temp = a;
  a = b;
  b = temp;
  return 0; 
}

int main() { 
  int i, j;
  i = 3; 
  j = 5;
  printf("SWAP 이전: i : %d, j : %d \n", i, j); 
  swap(i, j); // swap 함수 호출~~
  printf("SWAP 이후: i : %d, j : %d \n", i, j); 
  return 0;
}

실행 결과는 아래와 같다.

 

SWAP 이전: i : 3, j : 5
SWAP 이후: i : 3, j : 5

 둘 사이에 바뀐 것이 아무 것도 없다. 이유는? 위와 마찬가지다. a, b, temp 모두 swap 함수 내에 있는 지역 변수이므로 main으로 꺼내올 수 없다. 이를 포인터로 연결해보자.

 

/* 두 변수의 값을 교환하는 함수 */

#include <stdio.h>
int swap(int *ai, int *bi){
    int temp = *ai;
    *ai = *bi;
    *bi = temp;

    return 0;
}

int main() {
    int i, j;
    i = 3;
    j = 5;

    printf("SWAP 이전 - i: %d, j: %d \n", i, j);
    swap(&i, &j); //swap 함수 호출
    printf("SWAP 이후 - i: %d, j: %d\n", i, j);
    return 0;
}

결론적으로 정리하면, 어떤 함수가 특정한 타입의 변수/배열의 값을 바꾸려면 함수의 인자는 반드시 그 타입을 가리키는 포인터를 이용해야 한다!이다.

 

함수의 원형

 

위의 코드에서는 swap 함수가 main 위에 쓰여 있다. 따라서 main을 실행할 때는 이미 swap 함수에 대한 코드를 읽었기 때문에 실행 가능하다. 만약 swap 함수 코드를 main 아래에 쓰면 어떻게 될까? 오류가 뜬다. 이를 방지하기 위해 우리는 main 위에다가 함수의 원형(prototype)을 선언한다. 코드를 보자.

 

#include <stdio.h>
int swap(int *a, int *b); // 이것이 바로 함수의 원형 
int main() {
  int i, j; 
  i = 3;
  j = 5;
  // ... (생략)
  
int swap(int *a, int *b) {
  int temp = *a; 
  *a = *b;
  *b = temp; 
  return 0;
}

위 코드에서는 swap의 prototype을 두 번째 줄에 선언했기 때문에 main 아래에 코드를 작성했음에도 실행이 가능하다. 이때, 함수의 원형을 보자. 기존의 함수에서는 int swap () {} 형태였는데 반해 원형에서는 인자까지만 받고 body 부분에 해당하는 중괄호 없이 세미콜론만 붙고서 끝난다. 이 한 줄 짜리 코드는 컴파일러에게 다음과 같은 메시지를 전한다.

 

"이 소스코드에 이러이러한 함수가 정의되어 있으니까 살펴보거라."

 

다시 말해, 컴파일러에게 이 소스코드에 사용되는 함수에 대한 정보를 제공하는 것이다. 이는 실제 프로그램에는 전혀 반영되지 않는 정보다. 하지만 앞서 했던 실수를 하지 않도록 도와준다.

 

배열을 인자로 받는 방법

 

배열의 주소값을 포인터로 받아서 작업하면 된다!

 

#include <stdio.h>
int add_number(int *parr); 
int main() {
  int arr[3]; 
  int i;

/* 사용자로부터3 개의원소를입력받는다. */ 

for (i = 0; i < 3; i++) {
     scanf("%d", &arr[i]);
   }
   add_number(arr);
  printf("배열의 각 원소 : %d, %d, %d", arr[0], arr[1], arr[2]);
  return 0; 
}

int add_number(int *parr) { 
  int i;
  for (i = 0; i < 3; i++) { 
    parr[i]++;
  }
  return 0; 
}

 

정리하면, "어떠한 함수가 특정한 타입의 변수/배열의 값을 바꾸려면 함수의 인자는 반드시 타입을 가리키는 포인터 형을 이용해야 한다!"이다.

 

함수 포인터


함수를 가리키는 포인터.

 

이제 구조체로 넘어가자..

반응형