C언어 | 함수

CHOI·2021년 7월 2일
1

C 언어

목록 보기
17/28
post-thumbnail
int slave(int my_money) {
  my_money += 10000;
  return my_money;
}

우리가 만약에 최대값과 최소값을 반환하는 것을 만들고 싶다고 해보자. 그래서 아래와 같이 코드를 만들었다.

int max;
if (a >= b) {
  max = a;
} else {
  max = b;
}

그런데 만약에 실제로 프로그래밍을 하다가 어떠한 두 변수의 최대값과 최솟값을 구하는 일이 계속해서 필요하다면 위 코드를 복사 붙여넣기를 하는게 좋을까? 만약 이러한 일이 100번 필요하면 100번 복사 붙여넣기를 하면 코드가 매우 지저분해지고 한눈에 확 들어오지 않는다. 그래서 우리는 이러한 기능을 함수로 만들기로 했다.

#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;
}

실행 결과

함수를 불러보자 : Hello!!
또 부를까? Hello!!

마치 마법의 기술처럼 주문을 만들어두고 필요할 때 마다 주문을 부리면 그 기술이 실행되는 것이다. 이것이 함수이다.

int

int print_hello() {
  // 잠시 생략
}

일단 정의 부분을 보자 int print_hello() 라고 써 있는 부분을 '정의'라고 한다.

먼저 우리가 보기에 친근한 int 가 보인다. 지금까지 int 를 배열과 변수를 저장하는 곳에서만 사용했는데 놀랍게 함수를 정의할 때도 사용이 된다.

여기서 int 는 다음과 같은 사실을 알려준다.

이 함수는 int 형 정보를 반환한단다~

여기서 '반환'이라는 말이 조금 낯설수 있다. 출력된다라는 말과 비슷하다고 생각하면 된다. 함수에는 출력된다가 반환된다라고 하고 영어로 return 이라고 한다.

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

여기서 마지막에 return 0; 라고 써 있는 부분은 이 함수가 0을 반환한다고 할 수 있다. 즉, 위와 같은 마술상자는 언제나 0이 출력된다. 이때 함수의 반환형이 int 이므로 0은 int 의 형태로 저장되어 나간다.

여기서 int 의 형태로 저장된다는 말은 0이라는 데이터가 메모리상에서 4바이트 공간을 차지하여 반환된다는 뜻이다.

함수의 이름

이제 함수의 이름부분을 살펴보자 위에서 대충 짐작할 수 있듯이 함수의 이름에 해당하는 부분은 print_hello 라는 부분이다. 끝에 () 는 함수 이름에 포함되지 않는다. 이 괄호는 이것이 함수라는 것을 알려주는 역할을 한다. 만약 괄호가 없다면 int print_helloe 끝에 단순히 ; 가 붙지 않다고 생각하여 오류를 출력하게 되기 때문에 함수 뒤에는 꼭 () 를 붙여줘야한다.

또한 주석에서도 설명했듯이 함수의 이름을 보고 이게 무슨일을 하는 함수인지 알 수 있는 함수명이 좋다. 만약에 함수 이름을 asdfg 라고 지었다면 우리는 이 함수가 무슨 역할을 하는지 알 수 없다

함수에 이름은 20자가 넘어가지 않도록 하는 것이 좋다.

함수의 몸체(body)

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

이제 함수가 무슨일을 하는지 알 수있는 부분을 보자. 이러한 부분을 함수의 몸체(body)라고 한다. 위 함수에서는 함수의 몸체가 printf("Hello!! \n"); 를 실행하고 0을 반환한다는 것을 쉽게 알 수 있다.

호출

printf("함수를 불러보자 : ");
print_hello();

printf("또 부를까? ");
print_hello();

이제 함수를 호출하는 부분을 보자. 함수를 불러내는 방법은 단순히 함수 이름을 쓰고 () 도 붙여주면 된다 그러면 함수가 실행되는데 보통 함수를 불러낸다/호출한다(call)이라는 말을 주로 사용한다.

뒤에 () 를 붙여줌으로서 '내가 지금 쓴 것은 함수다'라고 말해준다.만약 괄호 없이 print_hello; 라고 쓰면 컴파일러는 이것을 함수로 해석하지 않고 단순히 어디선가에 print_hello 라는 변수에 접근했다고 생각하고 print_hello 라는 변수가 없기 때문에 오류를 출력하게 된다.

함수를 호출하면 프로그램은 함수 내용을 실행하고 나서 다시 원래 실행되려고 했던 부분으로 돌아간다.

이와 같은 상황은 실생활에서도 일어난다. 예를 들어서 밥을 먹고 있다가 '엄마가 부르신다' 라는 함수가 호출되면 밥을 먹다가 엄마한테 달려간다. 그리고 '엄마가 부르신다' 함수가 종료되면 다시 밥 먹던 식탁으로 돌아와서 마저 밥을 먹게 되는 것이다. 이 때, 함수가 종료는 두 가지 형태가 있는데 하나는 반환되어 종료되는 것이고 다른 하나는 함수가 끝 부분까지 실행되어 종료되는 것이다. 함수는 반환되어 종료되는 것이 안전하다. 한 가지 중요한 사실은 return 을 실행하면 함수는 종료되어서 함수를 호출했던 부분으로 다시 돌아가게 된다는 것이다. 이해가 안된다면 아래 예제를 보자

#include <stdio.h>
int return_func() {
  printf("난 실행된다 \n");
  return 0;
  printf("난 안돼 ㅠㅠ \n");
}
int main() {
  return_func();
  return 0;
}

실행 결과

난 실행된다

위를 보면

int main() {
  return_func();
  return 0;
}

여기서 함수 return_func 이 호출되어 함수 내용 부분으로 가게 되고

int return_func() {
  printf("난 실행된다 \n");
  return 0;
  printf("난 안돼 ㅠㅠ \n");
}

여기서 첫 번째 printf 가 실행되고 나서 return 을 하여 함수가 종료되고 다시 원래 main 으로 돌아가서 함수에 있던 두 번째 printf 는 실행되지 않은 것을 볼 수 있다.

#include <stdio.h>
int ret() { return 1000; }
int main() {
  int a = ret();
  printf("ret() 함수의 반환값 : %d \n", a);

  return 0;
}

실행 결과

ret() 함수의 반환값 : 1000

위의 예제를 살펴보자

int ret() { return 1000; }

여기서 함수 ret 이 정의 된 것을 볼 수 있는데 상당히 간단하다. 위 함수는"이 함수가 호출되면 1000을 리턴한다" 라고 말할 수 있을 것이다. 그리고 다음을 보자

int main() {
  int a = ret();
  printf("ret() 함수의 반환값 : %d \n", a);

  return 0;
}

위는 ret() 함수를 호출하고 그 값을 변수 a 에 대입하는 문자이다. 그런데 ret() 가 가지고 있는 값이 있을까? 물론, ret() 은 함수이기 때문에 위와 같이 사용할 수 없을 것 같다. 그런데 ret() 을 코드에 쓰게 된다면 이 말은 " ret() 함수의 반환값" 이라는 의미를 가지게 된다. 따라서 컴퓨터가 위 코드를 실행하게 되면 ret() 의 반환값인 1000이 a 에 들어가게된다.

아무튼 아래의 유명한 격언을 기억해두자

호랑이는 죽어서 가죽을 남기고, 함수는 죽어서 리턴값을 남긴다!

메인(main) 함수

아마도 이미 눈치챈 사람들도 있겠지만 이미 int main() 이라는 부분도 함수를 정의하고 있다는 것을 알 수 있다. 맞다. main 도 함수이다. 그런데 왜 하필 'main'일까? 그 이유는 컴퓨터는 프로그램을 실행할 때 main 함수부터 찾기 때문이다.(물론 모든 경우가 그런 것은 아니고 적어도 우리가 앞으로 만들게 될 C 프로그램들의 경우) 즉, 컴퓨터는 프로그램을 실행할 때 main 함수를 찾아서 호출함으로써 시작하게 된다. 만일 main 함수가 없으면 컴퓨터는 어디에서 부터 시작해야 할지 몰라서 오류가 발생하게 된다.

보통 메인함수는 아래와 같이 정의한다.

int main()

앞서 배운 내용을 살짝 활용해보면 위 함수는 int 형을 리턴하고 함수의 이름은 'main' 이라는 것을 알 수 있다. 그런데 main 함수의 리턴을 하면 누가 받을까? 메인 함수가 맨 처음 실행되는 함수라면 맨 마지막으로 종료되는 함수도 메인 함수 이기 때문에 메인 함수의 리턴을 받을 수 있는 함수는 없을 듯 하다.

그런데 사실 그렇지 않다. 메인 함수의 리턴하는 데이터는 운영체제가 받아들인다. 운영체제. 즉 우리가 이용하고 있는 Windows 10 이나 maxOS 에서 받아들인다는 것이다.

메인 함수가 정상적으로 종료되면 0을 리턴하고 비정상적으로 종료되면 1을 리턴한다는 규정이 있다. 사실 1을 리턴한다고 해서 큰 문제는 없다. 이 정보를 활용하는 경우는 매우 드물기 때문이다.

아무튼 여기서 알아야 할 사실은" main 도 함수이다" 이 정도만 알아두면 된다.

이번에는 값을 넣으면 4가 더해진 값으로 나오는 함수를 만들어보자 그리고 그 함수의 이름을 마법상자(magicbox)라고 해보자

#include <stdio.h>
int magicbox(){
    i += 4;
    return 0;
}

int main(){
    int i;
    
    printf("상자에 들어갈 값: ");
    scanf("%d" , &i);

    magicbox();
    printf("나오는 값 : %d\n",i);
    
    return 0;
}

컴파일 오류

error C2065: 'i' : 선언되지 않은 식별자입니다.

그런데 실행하면 오류가 발생한다. 분명히 우리는 main 함수에서 i 를 선언했고 main 함수가 가장 먼저 실행되니까 다른 함수에서도 사용할 수 있어야 하는게 아닌가? 했지만 안타깝게도 안된다.

어떠한 함수를 호출할 때, 호출된 함수는 호출한 함수에 대해서 어떠한 정보도 가지고 있지 않다. 즉, 내가 magicbox 라는 함수를 호출하면 이 magicbox 는 내가 호출한 것인지, 다른 녀석이 호출한 것인지(즉,다른 코드가 호출한 것인지) 전혀 알 수 없다.

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

따라서 이 함수는 i 라는 변수에 대해서 아무런 정보도 가지지 않고 있다.왜냐하먄 이 함수를 호출한 것이 무엇인지에 대한 정보가 아무것도 없기 때문이다. 결론적으로 main 에서 정의된 imagicbox 입장에서는 들어보지도 못한 것이다. 따라서 위와 같이 오류가 발생하고 i 라는 변수가 선언되지 않았다고 하는 것이다.

아직도 위의 내용이 이해가 되지 않는 다면 아래의 이야기를 한 번 보자

옛날 옛날 이집트 시대에 어떤 부유한 귀족이 있었습니다. 이 귀족은 하루에 10000 달러씩 장사를 해서 벌었습니다. 그런데 공교롭게도 수학을 매우매우 못했죠. 따라서, 이 귀족은 노예를 한 명 사서, 이 노예에게 자신의 현재 재산에 10000 을 더해서 알려 달라고 하였습니다. 그리고 시간이 흘러 10 시간 뒤, 귀족의 일과가 끝났습니다. 이제, 그는 오늘 자신의 재산 현황을 파악하기 위해서 노예를 호출했습니다.

야 말해

그런데 노예는 아무 말도 하지 못했습니다.

야 말하라고, 내 재산에 10000 을 더해서 말하라니까

역시 아무말도 없었습니다. 왜일까요? 그야, 당연히 노예는 귀족의 재산에 대한 정보가 없었기 때문입니다. 귀족이 방금 노예를 호출함으로써 한 일은, "자신의 재산 += 10000" 이였습니다. 그런데, '자신의 재산' 이란 변수는 노예의 머리에서 정의된 것이 아니므로 알 노릇이 없습니다.

그렇다면, 이제 아무 쓸모 없게된 불쌍한 노예를 악랄한 귀족이 죽이게 내버려 두어야 하나요? 물론, 그리하면 안되겠죠. 일단, 여기서 문제점을 해결하기 위해선 노예가 "현재 귀족의 재산" 이라는 데이터만 머리에 넣고 있으면 됩니다. (노예가 계산을 충분히 잘한다는 가정 하에..) 이 말을, C 언어 적으로 이야기 하면 노예라는 함수에 "주인의 현재 재산" 이라는 변수를 정의하고 이 변수에 "자신(주인)의 재산" 의 값을 넣은 뒤에, "주인의 현재 재산+=10000" 을 계산한 후, "주인의 현재 재산" 을 반환(입으로 말함) 하면 되는 것입니다.

이제, 문제는 노예 머리속에 "주인의 현재 재산" 이라는 변수에 "자신(주인) 의 재산" 값을 어떻게 넣느냐가 문제 이다. 바로 아래 예제를 보자

함수의 인자

#include <stdio.h>
int slave(int master_money) {
  master_money += 10000;
  return master_money;
}
int main() {
  int my_money = 100000;
  printf("현재 재산 : $%d \n", slave(my_money));

  return 0;
}

실행 결과

현재 재산 : $110000

일단 차근차근 살펴보자

int slave(int master_money)

이 부분은 다음을 의미한다

"나를 호출하는 코드로 부터 어떤 값을 mater_money 라는 int 형 변수에 인자(혹은 매개변수라고도 부름)로 받아들이겠다!"

먼저 '인자'가 무엇인지 살펴보자. 아까 노예의 이야기에서 문제는 노예가 '주인의 현재 가지고 있는 재산'이라는 값을 어떻게 처리할지가 문제였다. slave 함수와 main 함수는 서로 별개의 함수이기 때문에 slave 함수는 main 함수 안의 변수를 사용할 수 없을 뿐더러 main 함수도 slave 함수의 변수가 무엇인지 알 수 없다.

하지만 인자(argument, 혹은 매개변수(parameter)라고 불린다.) 를 이용하면 이러한 일이 가능하게 된다. 이때 , 인자는 직관적으로도 알 수 있듯이 함수 slave 에 정의된 변수이다. 이 때, 인자는 함수를 정의할 때 소괄호 안에 나타나게 된다. 위의 경우 slave 함수는 int 형의 master_money 라는 변수를 인자로 가진다. 이제 이 함수를 어떠한 함수에서 호출한다고 하면 인자에 적당한 값을 넣어주면 된다. 마치 아래와 같이

slave(500);

이 말은 **slave 함수를 호출할 때, slave 함수 안에서 정의된 master_money 라는 변수에 500이라는 값을 전달하겠다** 라는 의미이다. 따라서 slave 함수에서 정의된 master_money 라는 변수에 500이 들어가게 된다. 그러면 아래는 어떨까?

slave(my_money);

마찬가지로 해석해보면 slave 함수가 호출될 때 slave 함수에서 정의된 master_money 라는 변수에 my_money의 값을 전달하겠다 라는 의미이다. 만약 my_money 에 1000이라는 값이 들어 있으면 master_money 라는 변수에 1000을 전달하겠다라는 뜻이다.

결론적으로 말하자면 함수의 인자는 '함수를 호출한 것과 함수를 서로 연결하는 통신 수단' 이라고 말할 수 있다. 이러한 연유에서 수학적 용어로는 틀리지만 C에서는 '매개 변수'라고 한다.

printf("현재 재산 : $%d \n", slave(my_money));

다음은 이 부분을 보면 %dslave(my_money) 의 리턴값을 넘겨주었다. 리턴값을 넣어주기 위해서 slave 함수를 호출하게 되고 이 함수의 인자로 my_money 의 값을 전달하게 된다 따라서 slave 함수에는 다음이 실행된다.

{
  master_money += 10000;
  return master_money;
}

master_money 에 my_money 의 값 즉, 100000이 들어가고 10000이 더해져 110000이라는 값을 반환하게 된다. 반환된 값과 기존의 값의 차이를 확인하기 위해서 아래 예제를 실행해보면

#include <stdio.h>
int slave(int my_money) {
  my_money += 10000;
  return my_money;
}
int main() {
  int my_money = 100000;
  printf("현재 재산 : $%d \n", slave(my_money));
  printf("my_money : %d", my_money);

  return 0;
}

실행 결과

현재 재산 : $110000
my_money : 100000

이렇게 되는 것을 알 수 있다. 이해가 안되는 사람들을 위해서 더 위 예제를 설명해보자면

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

여기서 slave 함수는 my_money 를 인자로 받고 있다. 여기서 중요한 점은 my_moneyslave 의 변수라는 것이다. 그렇다면 위 함수를 호출한 부분을 보자

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

  return 0;
}

slave 함수를 호출할 때 main 함수 내부에서 정의된 변수 my_money 의 값을 slave 함수의 변수 my_money 에 전달했다. 즉, 각 함수 내부에서 정의된 my_money이름은 같지만 서로 다른 변수이고, 메모리상에서도 서로 다른 위치를 점유하고 있다. 따라서 우리가 보기엔 똑같은 변수이지만 컴퓨터 입장에서는 서로 전혀 다른 변수인 것이다.

두 번째로 주목해야 할 점은 값이 전달된다는 것이다. 이는 누누이 강조해 왔던 점인데 slave 함수를 호출할 때 slave 함수의 my_money 인자에는 값이 전달된다. 즉, main 함수의 변수 my_money 의 10000이라는 값이 slave 함수의 my_money 인자에 저장되어 들어가게 된다.

따라서 slave 함수에서 my_money 의 값을 아무리 지지고 볶아도 main 함수에서 my_money 변수에는 전혀 영향을 끼치지 않는 것이다. 왜냐하면 slave 함수의 my_moneymain 함수의 my_money같은 값을 가지고 있는 채로 초기화된 메모리상의 다른 변수이기 때문이다. 이건 마치

int a = b;
b++;

위와 같이 b에 + 1을 하였는데 a도 같이 + 1 이 되도록 바라는 것과 마찬가지이다. 아무튼 결과적으로 main 함수에서 두 번째 printf 문에서 main 함수의 my_money 의 값을 출력했을 때 에는 전혀 변하지 않은 100000 이 출력된다.

포인터 사용하기

이제 그동안 배웠던 포인터를 함수에서 사용해보자. 포인터를 간단하게 복습하자면 포인터는 특정 데이터의 주소값을 저장하는 변수로 int 형 데이터의 주소값을 저장하면 int * , char 이면 char * 로 표현한다. 또한
* 단항 연산자를 이용하면 자신이 가리키는 변수를 지칭할 수 있고 , 주소 연산자 & 를 이용하면 특정 변수의 주소값을 알아낼 수 있다.

만약에 위 내용중 하나라도 잘 생각나지 않으면 포인터를 다시보고 오길 강력하게 권한다.

그리고 다음 예제를 보자

#include <stdio.h>
int change_val(int i) {
  i = 3;
  return 0;
}
int main() {
  int i = 0;

  printf("호출 이전 i 의 값 : %d \n", i);
  change_val(i);
  printf("호출 이후 i 의 값 : %d \n", i);

  return 0;
}

실행 결과

호출 이전 i 의 값 : 0
호출 이후 i 의 값 : 0

앞에서 공부했어서 위의 예제의 결과로 왜 i 값이 변하지 않았는지는 알 수 있을 것이다.

그리고 여기서 포인터의 아이디어를 적극 활용할 것이다. 이전에 다른 함수에 정의된 변수들의 값을 변경할 때 직면 했었던 문제는 바로 각 함수는 다른 함수의 변수에 대해서 전혀 아는 것이 없다는 것이였다. 즉, A 라는 함수에서 i 라는 변수를 정의하면 컴파일러는 이 변수 i 가 함수 A 에서만 정의 되었다고 생각하지 다른 함수에서 정의되었는지 전혀 신경도 쓰지 않는다는 것이다.

그렇지만 궁여지책으로 유일하게 가능 했던 방법은 인자를 사용하여 다른 함수에 정의된 변수의 '값'을 인자로 전달하는 것이였다. 하지만 그래도 여전히 불가능해 보였다.

#include <stdio.h>
int change_val(int *pi) {
  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 의 값이 0에서 3으로 변했다.

int change_val(int *pi)

일단 위 함수는 인자로 pi 라는 포인터를 받는다. 그리고 main 함수를 보면

change_val(&i);

인자에 main 에서 정의된 변수 i 의 주소값을 인자로 전달하였다. 따라서 change_val 함수를 호출했을 때 pi 에는 i 의 주소값이 들어가게 된다. 즉, pii 를 가리키게 된다.

{
  printf("----- chage_val 함수 안에서 -----\n");
  printf("pi 의 값 : %p \n", pi);
  printf("pi 가 가리키는 것의 값 : %d \n", *pi);

  *pi = 3;

  printf("----- change_val 함수 끝~~ -----\n");
  return 0;
}

따라서 pi 를 출력해보면 i 와 같은 주소값을 출력한다. 그리고 *pi 를 통해서 i 에 간접적으로 접근할 수 있다. 왜냐하면 * 단항 연산자의 의미이는 "내가 가지고 있는 주소값에 해당하는 변수를 의미해라" 이기 때문이다. 따라서 *pi 에 의미는 pi 가 가리키고 있는 변수 i 를 의미할 수 있게 된다. 따라서 pi 를 통해서 굳게 떨어져 있던 mainchange_val 함수 사이에 다리가 놓이게 되는 것이다.

간혹 pimain 에서 정의된 것이라고 생각하는 사람이 있는데 change_val 함수에서 정의된 변수이다.

또한 *pi = 3 을 통해서 pi 가 '가리키고 있는 변수' 의 값을 3으로 변경하였는데 여기서 pii 를 가리키고 있어 i 의 값을 3으로 변경이 가능하다.

위 과정을 그림으로 그리면 아래와 같다.

두 변수를 교환하는 함수

#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

위의 예제를 보면 주석을 통해서 대충 짐작할 수 있듯이 두 변수의 값을 서로 바꿔주고 싶었지면 결과를 보면 성공하지 못했다. 우선 새롭게 보이는 것을 먼저 보자

int swap(int a, int b)

여기서 알 수 있는 것은 인자를 여러개 줄 수 있다는 사실이다. 위 처럼 2개 뿐만 아니라 , 를 계속해서 사용한다면 인자의 개수를 계속해서 늘릴 수 있다.

이제 그림을 보자


사실 그림이 없어도 위의 예제를 이해하는데 큰 무리는 없을 것이다. 결론적으로 얘기하자면 ab 를 아무리 지지고 볶아도 다른 함수에 있는 변수 ij 에는 전혀 영향을 끼치지 않기 때문에 바뀌지 않는 결과가 나오는 것이다.

그렇다면 이제 포인터를 이용해보자

#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);

  printf("SWAP 이후 : i : %d, j : %d \n", i, j);

  return 0;
}

실행 결과

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

이러면 우리가 드디어 원했던 결과를 얻을 수 있다. 아마도 이해하는데 큰 문제는 없을 것이다. 그러나 이해가 잘 안되는 사람들을 위해서 설명을 해주면

swap(&i, &j);

인자로 ij 의 주소값을 전해주었고

int swap(int *a, int *b) {
  int temp = *a;

  *a = *b;
  *b = temp;

  return 0;
}

각각 포인터 ab 에 들어가게 된다 그리고 a 에 해당하는 값을 temp 라는 변수에 저장하였다. 왜 그런가 싶었는데 다음 문장을 보면 알 수 있다. *a = *b; 를 통해서 a 에 해당하는 값에 b 에 해당하는 값(여기선 5이다.) 를 넣어 주었고 이렇게 되면 만약 a 에 해당하는 값(3)을 다른 곳에 미리 저장해두지 않으면 덮여씌여지 때문에 변수 temp 를 정의하고 5라는 값을 넣어준 것이다. 그리고 변수 b 에는 기존에 a 에 있던 값(temp 에 저장된 3) 을 넣어주었다. ai 를, bj 를 가리키므로 각각 5와 3이 대입되어서 우리가 원하는 결과를 얻을 수 있는 것이다.

따라서, swap 함수 내부에서는 ab 의 값을 교환하는 것이 아니라 ab 가 가리키는 두 변수의 값을 교환했으므로 (*a, *b) 결과적으로 ij 의 값이 바뀌게 된 것이다.

결론적으로 정의하자면

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

라는 것이다. 포인터를 인자로 받은 함수에 대해선 지속적으로 이야기 할 것이므로 지금 막상 이해가 잘 안된다고 해도 큰 걱정할 필요는 없다.

함수의 원형

우리가 사용하고 있었던 함수들은 지금까지 main 함수 위에 정의 되었었다. 하지만 대부분의 사람들은 main 함수를 제일 위에 놓고 나머지 함수들을 main 함수 아래에 정의한다. 한 번 이와 같이 해보자

#include <stdio.h>
int main() {
  int i, j;
  i = 3;
  j = 5;
  printf("SWAP 이전 : i : %d, j : %d \n", i, j);
  swap(&i, &j);
  printf("SWAP 이후 : i : %d, j : %d \n", i, j);

  return 0;
}
int swap(int *a, int *b) {
  int temp = *a;

  *a = *b;
  *b = temp;

  return 0;
}

컴파일 오류

warning C4013: 'swap'이(가) 정의되지 않았습니다. extern은 int형을 반환하는 것으로 간주합니다.

일단 경고창이 발생한다. 여기서 더이상 진행이 안될 수 도 있고 무시하고 진행할 수 있는 사람들은 무시하고 진행해보면

잘만 된다. 그렇다면 문제가 없는 것일까?

사실 문제가 있다. 만약에 함수 호출 부분 swap(&i, &j);swap(&i); 로 바꾸거나 swap(&i, j); 로 바꿔서 실행해보면 오류가 발생한다. 그러나 어느 부분에서 오류가 발생했는지는 알 수 없다. 지금은 코드가 짧아서 찾기가 쉽지만 실제로 코드를 사용할 때는 코드가 100줄, 1000줄을 넘어갈 수 있다. 그렇게 되면 어떤 부분에서 문제가 발생했는지 찾기가 사실상 불가능 하다. 그렇다면 어떻게 할까?

C언어에서는 훌륭한 해결책이 있다. 바로 함수의 원형(prototype) 를 사용하는 것이다.

#include <stdio.h>
int swap(int *a, int *b);  // 이 것이 바로 함수의 원형
int main() {
  int i, j;
  i = 3;
  j = 5;
  printf("SWAP 이전 : i : %d, j : %d \n", i, j);
  swap(&i, &j);
  printf("SWAP 이후 : i : %d, j : %d \n", i, j);

  return 0;
}
int swap(int *a, int *b) {
  int temp = *a;

  *a = *b;
  *b = temp;

  return 0;
}

실행 결과

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

문제없이 잘 해결되었다. 여기서 새로 생긴 부분은 다음과 같다.

int swap(int *a, int *b);

이 부분이 '함수의 원형' 이라 부르는 것이다. 이는 사실 함수의 정의 부분을 한 번 더 써준 것 뿐이다.(주의할 점은 함수의 정의 부분과는 달리 뒤에 ; 가 있다.) 그런데 이 한줄이 컴파일러에게 다음과 같은 사실을 말해준다.

야, 이 소스코드에 이러 이러한 함수가 정의되어 있으니까 잘 살펴봐

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

위와 같이 함수의 원형을 넣은 상태에서 인자를 하나 빼보자 즉, swap(&i, &j);swap(&i); 로 바꾸어보자

컴파일 오류

 error C2198: 'swap' : 호출에 매개 변수가 너무 적습니다.

그러면 이와 같이 어떤 부분이 잘못됬는지 알려준다. 이와 같이 정확하게 알려줄 수 있는 이유는 우리가 함수의 원형을 사용하면서 컴파일러에게 내가 어떤 함수를 사용할 것인지 알려주었기 때문이다. 내가, int swap(int *a, int *b) 라는 함수가 있다는 것을 원형을 통해 알려주었기 때문에 컴파일러는 swap 함수를 이용할 때 꼭 인자 2개를 주어야 한다는 것을 알게 되었고 내가 인자를 하나만 이용하면 문제인지 바로 알고 알려주는 것이다.

그렇다면, swap(&i, &j);swap(&i, j); 로 바꿔주면 어떻게 될까?

컴파일 오류

warning C4047: '함수' : 'int *'의 간접 참조 수준이 'int'과(와) 다릅니다.
warning C4024: 'swap' : 형식 및 실제 매개 변수 2의 형식이 서로 다릅니다.

오류가 발생하지 않지만 내가 잘못했다고 알려준다. 컴파일러는 원형을 통해서 내가 두 번째 매개 변수의 타입이 무엇인지 알기 때문에 그냥 int 를 사용한다면 내가 두 번째 매개 변수로 전해줘야 하는 타입과 다르다는 사실을 알려준다. 그러나 오류가 발생하지 않고 경고만 하는 이유는 사실 int*int 형이기 때문에 j(int *) 형으로 전달되어도 아까와 같은 강력한 오류가 발생하지 않는다.

이러한 이유로 함수의 원형을 집어넣는 일은 반드시 해야한다. 물론 main 함수 위에 함수를 정의하면 필요 없지만 사실 99% 의 프로그래머들이 main 뒤에 정의하고 함수 원형을 사용하는 것을 선호하니 트렌드를 따라가야 한다.

배열을 인자로 받기

이번에는 배열을 인자로 받아 들이는 함수에 대해서 생각해보자. 다음 나올 예제는 배열을 인자로 받아 들이고 각 원소의 값을 1 씩 증가시키는 함수이다.

#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;
}

실행 결과

10
11
15
배열의 각 원소 : 11, 12, 16

일단 여기부터 보자

int add_number(int *parr)

우리가 앞서 "특정한 타입의 값을 변경하려면 반드시 그 타입을 가리키는 포인터를 인자로 가져야 한다."고 했다 따라서 arr 를 가리키는 포인터가 add_number 함수의 인자로 와야 한다. arr 는 1 차원 배열로 포인터는 int * 형 이므로 위와 같이 하면 arr 을 가리키도록 인자를 받을 수 있다.

호출부분을 보면 다음과 같다

add_number(arr);

arr = &arr[0] 과 같다고 포인터에서 배웠으니까 parr 에는 arr 의 시작 주소, 즉 배열 arr 를 가리키게 된다.

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

마지막으로 함수 몸체 부분을 보면 parr[i] 를 통해서 parr 가 가리키는 배열의 (i + 1) 번째 원소에 접근할 수 있고 ++를 통해 모두 1 식 증가하게 된다. 사실 위 함수가 어떻게 진행되는지 이해하기 위해선 포인터와 배열에 대해서 완벽하게 이해해야 한다. 만약 그렇지 않다면 모래사장 위에 빌딩을 짓는 것 처럼 C 언어에 대한 개념을 완전히 잊어버릴 수 있기 때문에 모른다면 꼭 복습하길 바란다.

#include <stdio.h>
/* max_number : 인자로 전달받은 크기 10 인 배열로 부터 최대값을 구하는 함수 */
int max_number(int *parr);
int main() {
  int arr[10];
  int i;

  /* 사용자로 부터 원소를 입력 받는다. */
  for (i = 0; i < 10; i++) {
    scanf("%d", &arr[i]);
  }

  printf("입력한 배열 중 가장 큰 수 : %d \n", max_number(arr));
  return 0;
}
int max_number(int *parr) {
  int i;
  int max = parr[0];

  for (i = 1; i < 10; i++) {
    if (parr[i] > max) {
      max = parr[i];
    }
  }

  return max;
}

실행 결과

100 50 102 300 900 700 550 400 800 600
입력한 배열 중 가장 큰 수 : 900

만약 앞선 예제를 문제 없이 이해 했다면 위 예제도 이해하는데 문제가 없을 것이다.

함수 사용 연습하기

아직까지 왜 함수를 이용하는지 왜 중요한지 감이 잘 오지 않을 수 있다 그렇다면 다음 예제를 보자

다음 두 소스코드 를 비교해서 어떤 것이 더 나은지 확인해보자

/* 함수를 이용하지 않은 버전 */
#include <stdio.h>
int main() {
  char input;

  scanf("%c", &input);

  if (48 <= input && input <= 57) {
    printf("%c 는 숫자 입니다 \n", input);
  } else {
    printf("%c 는 숫자가 아닙니다 \n", input);
  }

  return 0;
}
/* 함수를 이용한 버전 */
#include <stdio.h>
int isdigit(char c);  // c 가 숫자인지 아닌지 판별하는 함수
int main() {
  char input;

  scanf("%c", &input);

  if (isdigit(input)) {
    printf("%c 는 숫자 입니다 \n", input);
  } else {
    printf("%c 는 숫자가 아닙니다 \n", input);
  }

  return 0;
}
int isdigit(char c) {
  if (48 <= c && c <= 57) {
    return 1;
  } else
    return 0;
}

일단 첫 번째 소스코드는 더 짧다. 그러나 이해하기가 어렵다

if (48 <= input && input <= 57) {
}

이 부분만 보면 이게 어떤 것을 의미하는지 모를 가능성이 높다. 사실 숫자의 아스키 코드가 48이상 57이하이기 때문에 위 코드를 사용했는데 아스키코드를 외우지 않는 한 이해하기 어렵다.

그런데 함수를 사용한 소스코드를 보면

if (isdigit(input)) {
}

isdigit 라는 이름만 보고도 이 함수가 무슨 일을 하는지 대충은 알 수 있어서 함수 몸체의 내용이 함수를 사용하지 않았던 소스코드와 동일하지만 이해하는데 도움을 준다. 또한 이와 동일한 일을 할 때 이 함수를 이용하면 되어서 더 편리하다.

복습

함수에서 포인터의 포인터

이전에 배운 내용에 대해서 잘 기억하고 있는지 한 번 확인해보자. 다시 한 번 요약하자면 어떠한 함수의 배열이나 변수의 값을 바꾸려면 그 타입의 포인터를 바꾸려는 함수의 인자로 사용해야 한다.

그러면 이제 예제를 한 번 보면서 복습을 해보자

#include <stdio.h>
int swap(int **x, int **y);
int main(){
    
    int a,b;
    int *pa, *pb;
    int **ppa, **ppb;
    
    pa = &a;
    pb = &b;
    
    ppa = &pa;
    ppb = &pb;
    
    printf("a 의 주소 : %p\n",&a);
    printf("b 의 주소 : %p\n",&b);
    printf("pa의 값 : %p\n",pa);
    printf("pb의 값 : %p\n",pb);
    printf("ppa의 값 : %p\n",ppa);
    printf("ppb의 값 : %p\n",ppb);
    
    
    swap(&pa,&pb);
    printf("-------------After Function ------------ \n");
    
    printf("a 의 주소 : %p\n",&a);
    printf("b 의 주소 : %p\n",&b);
    printf("pa의 값 : %p\n",pa);
    printf("pb의 값 : %p\n",pb);
    printf("ppa의 값 : %p\n",ppa);
    printf("ppb의 값 : %p\n",ppb);
    
    return 0;
    
}

int swap(int **x, int **y){
    int *temp = *x;
    *x = *y;
    *y = temp;
    return 0;
}

실행 결과

a 의 주소 : 0x16fdff3f8
b 의 주소 : 0x16fdff3f4
pa의 값 : 0x16fdff3f8
pb의 값 : 0x16fdff3f4
ppa의 값 : 0x16fdff3e8
ppb의 값 : 0x16fdff3e0
-------------After Function ------------ 
a 의 주소 : 0x16fdff3f8
b 의 주소 : 0x16fdff3f4
pa의 값 : 0x16fdff3f4
pb의 값 : 0x16fdff3f8
ppa의 값 : 0x16fdff3e8
ppb의 값 : 0x16fdff3e0

매우 복잡하다.

하지만 바뀐 결과만 보면 pa에 들어있는 값과 pb에 들어있는 값이 서로 바뀌었다. 위 소스코드에서는 포인터의 값을 바꾸기 위해서 포인터의 포인터를 사용하였다. 마치 우리가 정수형 변수를 바꾸기 위해서 정수형 포인터를 사용한 것 처럼 포인터의 값을 바꾸기 위해서는 포인터의 포인터를 사용하면 된다.

함수에서 2차원 배열

#include <stdio.h>
/* 열의 개수가 2 개인 이차원 배열과, 총 행의 수를 인자로 받는다. */
int add1_element(int (*arr)[2], int row);
int main() {
  int arr[3][2];
  int i, j;

  for (i = 0; i < 3; i++) {
    for (j = 0; j < 2; j++) {
      scanf("%d", &arr[i][j]);
    }
  }

  add1_element(arr, 3);

  for (i = 0; i < 3; i++) {
    for (j = 0; j < 2; j++) {
      printf("arr[%d][%d] : %d \n", i, j, arr[i][j]);
    }
  }
  return 0;
}
int add1_element(int (*arr)[2], int row) {
  int i, j;
  for (i = 0; i < row; i++) {
    for (j = 0; j < 2; j++) {
      arr[i][j]++;
    }
  }

  return 0;
}

실행 결과

1 2 3 4 5 6
arr[0][0] : 2
arr[0][1] : 3
arr[1][0] : 4
arr[1][1] : 5
arr[2][0] : 6
arr[2][1] : 7

잘 실행이 된다.

int add1_element(int (*arr)[2], int row) {
  int i, j;
  for (i = 0; i < row; i++) {
    for (j = 0; j < 2; j++) {
      arr[i][j]++;
    }
  }

  return 0;
}

일단 함수 정의 부분을 보면 인자로 두 개 받고 있다.

  1. 열의 개수가 2인 이차원 배열을 가리키는 포인터
  2. 행의 수
for (i = 0; i < row; i++) {
  for (j = 0; j < 2; j++) {
    arr[i][j]++;
  }
}

우리는 가리키는 원소의 크기와 열의 개수 만 알면 2차원 배열의 각각의 원소에 접근이 가능하다는 것을 이전에 포인터에서 배웠었다. 따라서 각각의 원소에 + 1 을 해주는 것 또한 가능하다.

int add1_element(int (*arr)[2], int row)

오직 함수의 인자에서만 위와 같은 형태를 아래와 같이 표현할 수 있다.

int add1_element(int arr[][2], int row)

이것은 오직 함수의 인자에서만 사용할 수 있는 것이다. 만약에 다른 곳에서

int parr[][3] = arr;

위와 같이 사용하면 컴파일러는 parr 배열이 열의 개수는 3으로 정해졌지만 행의 개수가 정해지지 않았다고 생각해서 오류를 내뿜은다.

이와 같은 개념을 활용해서 고차원 배열도 다음과 같이 인자를 정의 할 수 있다.

int multi(int (*arr)[3][2][5]) {
  arr[1][1][1][1] = 1;
  return 0;
}

혹은

int multi(int arr[][3][2][5]) {
  arr[1][1][1][1] = 1;
  return 0;
}

상수인 인자

#include <stdio.h>
int read_val(const int val);
int main() {
  int a;
  scanf("%d", &a);
  read_val(a);
  return 0;
}
int read_val(const int val) {
  val = 5;  // 허용되지 않는다.
  return 0;
}

컴파일 오류

 error C2166: l-value가 const 개체를 지정합니다.

왜 오류가 발생할까??

우리는 valconst int 로 선언했다. 따라서 함수를 호출 할 때 val 의 값은 인자로 전달된 값(a의 값 1)으로 초기화 되고 절대로 바뀌지 않는다. 따라서 val = 5 같은 일을 하면 오류가 발생하는 것이다. 왜냐하면 val 는 상수니까.

함수 포인터

함수 포인터? 처음 들으면 조금 의야할 수 있다. 함수를 가리키는 포인터? 그렇다면 함수도 메모리에 저장되어 주소값을 가지고 있다는 말인가? 맞다. 사실 프로그램의 코드 자체가 메모리상에 존재한다. 우리는 이전에 컴파일러가 하는 작업에 대해서 다음과 같이 설명했다 '인간에게 친숙한 언어'로 만들어진 프로그램 코드를 '컴퓨터에게 친숙한 언어 즉, 수 데이터들'로 바꿔주어 실행 파일을 생성한다. 이렇게 바뀐 파일을 실행하게 되면 프로그램 수 코드가 메모리상에 올라가게 된다. 다시 말해서 메모리상에 함수 코드가 올라간다는 의미이다. 이 때, 변수를 가리키는 포인터처럼 함수 포인터는 메모리상에 올라간 함수의 시작 주소를 가리키는 역할을 한다.

그렇다면 함수 포인터는 함수를 가리키기 위해서 그 함수의 시작 주소를 알아야 한다. 그런데 배열과 마찬가지로 함수도 함수의 이름이 그 함수의 시작주소를 나타낸다.

#include <stdio.h>

int max(int a, int b);
int main() {
  int a, b;
  int (*pmax)(int, int);
  pmax = max;

  scanf("%d %d", &a, &b);
  printf("max(a,b) : %d \n", max(a, b));
  printf("pmax(a,b) : %d \n", pmax(a, b));

  return 0;
}
int max(int a, int b) {
  if (a > b)
    return a;
  else
    return b;

  return 0;
}

실행 결과

10 15
max(a,b) : 15
pmax(a,b) : 15

일단 잘 실행된다. 하나 씩 살펴보자

int (*pmax)(int, int);

일단 위는 함수 포인터 pmax 의 정의이다. 위를 보고 알 수 있는 사실로는 다음과 같다.

  1. 이 함수 포인터 pmax 의 리턴값은 int 형이다.
  2. 이 함수는 int 형 인자를 두 개 받는다.

우리가 함수 포인터 pmax 로 특정 함수를 가리킬 때는 그 함수가 반드시 pmax 의 정의와 일치해야 한다. 함수 포인터의 일반적인 정의는 다음과 같다.

(함수의 리턴형) (*포인터 이름)(첫번째 인자 타입, 두번째 인자 타입,....)
// 만일 인자가 없다면 그냥 괄호 안을 비워두면 된다. 즉, int (*a)() 와 같이 하면 된다

이제 pmaxmax 를 가리키는 부분을 보자

pmax = max;

max 함수가 pmax 의 정의와 일치하므로 max 의 시작주소를 pmax 에 대입할 수 있다. 이 때 앞서 말했듯이 특정 함수의 시작 주소값을 알려면 그냥 함수의 이름을 넣어주면 된다. pmax = &max 는 틀린 형식이다.

printf("max(a,b) : %d \n", max(a, b));
printf("pmax(a,b) : %d \n", pmax(a, b));

pmax 는 이제 max 를 가리키기 때문에 pmax 를 통해서 max 에서 할 수 있던 모든 작업을 할 수 있게 된다. 이때도 역시 그냥 pmaxmax 처럼 이용하면 된다. 이는 배열에서

int arr[3];
int *p = arr;

arr[2];  // p[2] 와 정확히 일치
p[2];

arr[2]p[2] 가 동일한 것과 마찬가지다. 아무튼 max(a,b) 를 하나 pmax(a,b) 를 하나 똑같은 것이다.

#include <stdio.h>

int max(int a, int b);
int donothing(int c, int k);
int main() {
  int a, b;
  int (*pfunc)(int, int);
  pfunc = max;

  scanf("%d %d", &a, &b);
  printf("max(a,b) : %d \n", max(a, b));
  printf("pfunc(a,b) : %d \n", pfunc(a, b));

  pfunc = donothing;

  printf("donothing(1,1) : %d \n", donothing(1, 1));
  printf("pfunc(1,1) : %d \n", pfunc(1, 1));
  return 0;
}
int max(int a, int b) {
  if (a > b)
    return a;
  else
    return b;

  return 0;
}
int donothing(int c, int k) { return 1; }

실행 결과

10 123
max(a,b) : 123
pfunc(a,b) : 123
donothing(1,1) : 1
pfunc(1,1) : 1

위 예제를 살펴보자

int (*pfunc)(int, int);

일단 포인터 함수 pfunc 를 정의했다. 이는 '리턴형이 int 이고 두 개의 int 인자를 받는 함수를 가리킨다'

그런데 위에 나온 함수 maxdonothin 함수는 위의 정의와 정확이 일치한다. 이 두 개의 함수는 각각 하는 일과 리턴하는 값은 다르지만 리턴형이 int 이고 두 개의 int 인자를 가지고 있으므로 pfunc 는 이 두개의 함수를 가리킬 수 있는 것 이다.

pfunc = max;

scanf("%d %d", &a, &b);
printf("max(a,b) : %d \n", max(a, b));
printf("pfunc(a,b) : %d \n", pfunc(a, b));

pfunc = donothing;

printf("donothing(1,1) : %d \n", donothing(1, 1));
printf("pfunc(1,1) : %d \n", pfunc(1, 1));

따라서 위와 같이 pfunc 는 자신이 가리키는 함수의 역할을 제대로 수행 할 수 있다.

인자의 형이 무엇인지 알기 힘들 때

int increase(int (*arr)[3], int row)

위의 함수의 원형을 보면 두 번째 인자가 int 형인건 알겠는데 첫 번째 인자는 무엇인지 잘 감이 안 올 수 가 있다. 이럴 때는 변수의 이름을 빼버리면 된다. 따라서 첫 번째 인자는 int (*)[3] 이다. 따라서 위 함수를 가리키는 포인터 함수의 원형은 다음과 같다.

int (*pfunc) (int (*)[3], int);

이와 같이 간단하다는 것을 알 수 있다. 이러한 아이디어는 이차원 배열을 인자로 받았던 함수에 적용해볼 수 있다.

profile
벨로그보단 티스토리를 사용합니다! https://flight-developer-stroy.tistory.com/

0개의 댓글