5. 함수
1. Intro
수학을 공부하다 보면 복잡한 수식을 f(x), g(x) 따위의 함수로 바꾸어 편하게 계산하곤 한다. 이렇게 함수를 사용하는 덕분에 복잡한 식을 보다 간단하게 풀고 직관적인 형식으로 표현할 수 있다. c언어에도 이러한 함수라는 개념이 존재한다. 함수를 사용하는 덕분에 길고 복잡하며 반복되는 코드를 보기 좋게 정리할 수 있으며, 체계적인 코드를 작성할 수 있다.
2. 함수의 선언
함수의 기본 틀은 다음과 같다.
int function(int argument) {
return argument;
}
[반환자료형] [함수이름]([인자자료형] [인자이름]) { // [1] [2] [3] [4]
[함수내용];
[함수반환값]; // [5]
}
[1]. 반환 자료형
c언어의 함수는 자판기와 비슷하게 동작한다. 입력을 받으면 입력에 대한 처리 결과를 반환한다. 자판기에서 콜라나 커피를 반환하는 것과 같이 c언어의 함수는 정수형 값이나 실수형 값, 문자 등을 반환할 수 있다. 하나의 함수는 단 한개의 값을 반환할 수 있는데(python같은 언어는 한 번에 여러개의 값을 반환할 수 있다), 그러한 반환값의 자료형이 바로 반환 자료형이다. int형 값을 반환하는 함수는 int function(), double형 값을 반환하는 함수는 double function()과 같이 선언하면 된다.
[2]. 함수 이름
함수의 이름을 결정하는 것은 굉장히 중요하다. 함수의 존재 목적이 직관적인 코드를 만들기 위함이라는 것을 생각해보았을때 당연한 부분임을 알 수 있다. int a(), double b()처럼 선언하면 나중에 코드를 보았을 때 a함수가 뭔지 b함수가 뭔지 굉장히 헷갈리는 상황이 펼쳐진다. 함수 명명 규칙에 대해 자세하게 설명한 문서가 웹에 많이 있으니 관심있으면 찾아보길 바란다.
[3]. 인자 자료형
인자란 함수에 던져주는 값이다. f(x)에서 x가 인자인 셈이다. 함수는 여러개의 인자를 받을 수 있다. 이러한 인자의 자료형을 인자 자료형이라 부르자.
[4]. 인자 이름
외부에서 던져준 인자값을 받을 변수의 이름이다.
[5]. 함수 반환값
반환 자료형과 일치하는 자료형의 값을 반환한다. 이때 사용되는 예약어가 return이다. main함수의 return 0;는 main함수를 종료하고 0이란 사실상 무의미한 값을 반환하겠다는 의미이다. 하지만 이러한 사용자 직접 만든 함수(사용자 정의 함수라 부른다)의 반환값은 보통 계산 결과와 같은 유의미한 값이 될 것이다.
물론 함수의 반환 자료형이 void인, 다시 말해 아무런 값도 반환하지 않는 함수 또한 충분히 정의할 수 있다. 이는 필요에 따라 적절히 사용하면 된다.
3. 함수의 사용
다음 예시를 통해 함수를 사용하는 방법에 대해 살펴보자.
2차원 좌표값을 4개를 받은 후, 처음 받은 2개의 좌표사이의 거리와 나중에 받은 2개의 좌표사이의 거리 중 더 긴 것의 거리를 출력하는 코드를 작성한다고 가정하자. 그럼 아래와 같은 코드를 짤 수 있다.
#include <stdio.h>
#include <math.h>
int main()
{
int x1, x2, x3, x4;
int y1, y2, y3, y4;
scanf("%d %d %d %d", &x1, &y1, &x2, &y2);
double distance1 = sqrt(pow((x1 - x2), 2) + pow((y1 - y2), 2)); // [1]
scanf("%d %d %d %d", &x3, &y3, &x4, &y4);
double distance2 = sqrt(pow((x3 - x4), 2) + pow((y3 - y4), 2));
double ans = distance1 > distance2 ? distance1 : distance2; // [2]
printf("%lf", ans);
return 0;
}
[1]의 sqrt는 루트 연산을, pow는 제곱 연산을 하는 함수이다. 둘 다 math.h에 정의된 함수이기 때문에 math.h를 include하였다.
[2]에서 사용된 것은 삼항연산자라는 것으로, 물음표 왼쪽의 식이 참이면 콜론 왼쪽의 값을, 거짓이면 오른쪽의 값을 반환하는 연산자이다. 즉 distance1이 distance2보다 크면(물음표 왼쪽의 식이 참이면) ans에는 distance1(:의 왼쪽 값)이 대입될 것이고 distance2가 distance1보다 크면(물음표 왼쪽의 식이 거짓이면) ans에는 distance2(:의 오른쪽 값)이 대입된다.
별로 복잡한 코드는 아니지만 아무래도 가독성이 떨어진다. 아무래도 아래 부분은 한눈에 들어오지 않는 식이다.
sqrt(pow((x1 - x2), 2) + pow((y1 - y2), 2));
반복되기도 하니 함수로 만들어 사용하면 코드가 훨씬 간단해질 것 같다. 결과값이 double형이고, 인자로는 x좌표값 2개, y 좌표값 2개 총 4개의 int형 인수를 받는다. 함수 이름은 getDistance로 하면 이해하기 쉬울 것 같다. 이러한 점을 고려해 만든 getDistance 함수는 아래와 같다.
double getDistance(int xa, int ya, int xb, int yb) {
return sqrt(pow((xa - xb), 2) + pow((ya - yb), 2));
}
이렇게 만든 getDistance함수를 끼얹은 코드는 다음과 같다.
#include <stdio.h>
#include <math.h>
double getDistance(int xa, int ya, int xb, int yb) {
return sqrt(pow((xa - xb), 2) + pow((ya - yb), 2));
}
int main()
{
int x1, x2, x3, x4;
int y1, y2, y3, y4;
scanf("%d %d %d %d", &x1, &y1, &x2, &y2);
double distance1 = getDistance(x1, y1, x2, y2);
scanf("%d %d %d %d", &x3, &y3, &x4, &y4);
double distance2 = getDistance(x3, y3, x4, y4);
double ans = distance1 > distance2 ? distance1 : distance2;
printf("%lf", ans);
return 0;
}
이런 식으로 복잡하고 반복해서 써야 하는 코드를 함수로 만들 경우 printf나 scanf함수처럼 간편하게 쓸 수 있고 코드의 오류를 찾는것과 수정하는 것이 쉬워진다.
실제 예시를 위해 스네이크 게임의 코드를 링크해두었다. 보기에 막 복잡해보이지만 하나하나 뜯어보면 충분히 이해할 수 있을 것이다.
4. 함수의 특수한 경우 - 재귀함수
재귀함수는 어떤 함수가 자기 자신을 호출하는 것을 말한다. 보통의 반복문으로는 계산하기 어려운 반복 연산을 위해 어쩔 수 없이 사용한다. 함수를 호출할 때 memory의 stack영역에 데이터가 저장된다. 재귀함수를 사용할 경우 함수를 여러번 호출하므로 stack 영역의 한계를 초과할 가능성이 있다. 이 경우 StackOverflowException이 발생한다. 재귀함수를 사용할 때에는 이러한 점을 염두하고 조심하여야 한다.
간단한 예제를 통해 재귀함수가 무엇인지 살펴보자.
어떤 수 n을 입력받아서 피보나치 수열의 n번째 수를 출력하는 코드를 재귀함수를 사용해 작성해보자.
#include <stdio.h>
int fibo(int n) {
if (n == 1 || n == 0) {
return n;
}
return fibo(n - 1) + fibo(n - 2);
}
int main()
{
int n;
scanf("%d", &n);
printf("%d",fibo(n)); // ★
return 0;
}
n에 5라는 값이 입력되었을 때 ★표시가 된 문장이 실행되는 과정을 살펴보자.
우선 fibo(5)가 호출된다. 이와 동시에 연산장치의 stack영역에 fibo(5)가 호출되었다는 정보가 저장된다. fibo(5)에서 5는 1이 아니고 0도 아니기 때문에 fibo(4)와 fibo(3)이 호출된다. fibo(4)에서는 비슷하게 fibo(3)과 fibo(2)가 호출되며, fibo(1)이나 fibo(0)이 호출될 때 까지 비슷한 과정을 반복한다. 이를 그림으로 나타내면 아래와 같다.
효율의 측면에서 보았을 때 재귀함수는 그다지 좋은 도구는 아니다. 위의 그림에서 fibo(3)이 몇 번 호출되었는지, 그리고 fibo(3)때문에 fibo(1)과 fibo(0)이 몇 번 호출되었는지 세어보자. 재귀함수 대신 배열을 써서 위 수열을 구한다면 약간의 공간을 더 소비하겠지만 이전에 계산한 수를 다시 계산할 필요는 없다.
하지만, 역으로 모든 상황을 계산해야 하는 문제에서 재귀함수는 문제해결을 위한 최고의 도구가 될 수 있다. N Queen이라는 문제가 있다. 체스판에 n개의 queen을 서로의 행마에 겹치지 않게(서로 잡아먹을 수 없게) 배치하는 방법을 찾는 문제이다. 다른 좋은 방법이 있는지는 모르겠지만, 일반적으로 완전탐색을 이용해 이 문제를 푼다. 가지치기 등을 통해 보다 효율적으로 계산할 수는 있겠지만, 가장 기본적이고 대략적인 매커니즘은 아래와 같다.
1. (0, 0)에 여왕을 배치한다.
2. (0, 1)에 새 여왕을 배치한 후, 행마가 겹치는지 확인한다.
3. 행마에 겹치므로 (0, 1)에 배치한 여왕을 제거한 후 (0, 2)에 여왕을 배치한다. 그 후, 행마가 겹치는지 확인한다.
4. 행마에 겹치므로 (0, 2)에 배치한 여왕을 제거한 후 (0, 3)에 여왕을 배치한다. 그 후, 행마가 겹치는지 확인한다.
...
a. 행마에 겹치므로 (2, 2)에 배치한 여왕을 제거한 후, (2, 3)에 여왕을 배치한다. 그 후, 행마가 겹치는지 확인한다.
b. 행마에 겹치지 않으므로 (2, 4)에 새 여왕을 배치한 후, 행마가 겹치는지 확인한다.
...
x. n개의 여왕이 모두 배치되었으면 해당 체스판을 정답으로 제출한다. n개의 여왕을 모두 배치할 수 있는 체스판이 없으면 '불가능'을 정답으로 제출한다.
이렇게 매 회마다 계산을 해야 하는 문제의 경우 재귀함수가 유용하게 사용된다.
5. Call by reference, Call by value, 그리고 Call by address
조금 복잡하고 어려운 내용이므로 그냥 그렇구나 하고 넘어가도 좋다.
어떤 '이상한 연산'이 있다고 가정하자. 그 '이상한 연산'의 내용은 아래와 같다.
1. '이상한 연산'은 하나의 인수를 취급한다.
2. '이상한 연산'의 결과는 인수에 5를 곱한 후 10을 더한 수와 같다.
'이상한 연산'을 하는 코드를 작성해보도록 하자.
#include <stdio.h>
int strangeCalculation(int k) {
return (k * 5) + 10;
}
int main()
{
int n;
scanf("%d", &n);
n = strangeCalculation(n);
printf("%d", n);
return 0;
}
위의 코드와 같이 인자로 변수의 "값"을 넘겨주며 함수를 호출하는 것을 Call by value라고 한다. Call by value의 경우 호출 시 넘겨진 값(n)을 호출된 함수(strangeCalculation)는 내부에서 선언된 변수(int k)에 받아 사용한다.
함수의 인자로 변수의 "실제 값"을 넘겨주지 않고 "변수 자체"를 넘겨줄 수 있다. 바구니에 담긴 사과를 꺼내주는 것이 아니라 바구니 자체를 넘겨주는 것이라고 보면 이해하기 쉬울 것이다. 이러한 호출 방식을 Call by reference라고 한다. Call by reference를 적용한 strangeCalculation은 다음과 같다.
#include <stdio.h>
int strangeCalculation(int &k) {
k = (k * 5) + 10;
}
int main()
{
int n;
scanf("%d", &n);
strangeCalculation(n);
printf("%d", n);
return 0;
}
함수 선언부의 함수의 인자를 보면 위의 call by value에서의 인자와 다르게 & 연산자가 붙어있다. 이것은 참조연산자로, 어떤 변수의 주소값을 의미한다. 즉 strangeCalculation의 k와 main의 n은 동일한 주소를 가진 동일한 변수가 되는 것이다. 그렇기 때문에 strangeCalculation 내부에서 k에 어떤 연산이 적용되면 결과적으로 main의 n에도 동일한 연산이 적용되는 것과 같은 결과가 나온다.
call by reference에서는 호출 부분에서 "변수 자체"를 넘겨주고 함수측에서 그 변수의 "주소값"을 참조해 내부의 변수를 만들었다.
call by address는 함수의 연산 결과가 호출시 넘겨준 변수에 적용된다는 비슷한 점이 있지만, 호출 부분에서 "변수의 주소값"을 넘겨주고 함수에서도 "변수의 주소값"을 받는다는 점에서 차이가 있다. call by address를 적용한 strangeCalculation은 아래와 같다.
#include <stdio.h>
int strangeCalculation(int* k) {
return (*k * 5) + 10;
}
int main()
{
int n;
scanf("%d", &n);
strangeCalculation(&n);
printf("%d", n);
return 0;
}
*(Asterisk)연산자는 곱셈 연산자로 사용되지만 이렇게 역참조 연산자로 사용되기도 한다. 위에서 언급된 &연산자의 반대 연산이라고 생각하면 쉬울 것 같다. *연산은 해당 주소에 있는 값을 반환한다.
굉장히 헷갈리는데, int* k는 포인터 변수인 "k"를 선언한다는 의미이다. 즉 함수 호출부분에서 넘겨진 포인터값(&n)을 포인터 값을 저장하는 변수인 포인터 변수 "k"로 받는 것이다. "k"에는 main함수의 변수 n의 주소값이 담겨져 있으니 변수 n이 가지고 있는 실제 값(scanf로 받은 값)을 사용하기 위해 역참조연산자 *를 k에 사용해 "*k"의 형태로 연산을 진행한다.
call by reference, call by address도 결국엔 어떠한 "값"을 함수에 넘겨주는 것이기 때문에 궁극적으로 call by value로 보는 시각이 존재하며, 본인도 그것이 틀리지 않았다고 생각한다. 하지만 본인도 C와 C++의 차이에 대해 잘 알지 못하고, 포인터에 대해서도 자세히 알고 있지 못하여 이 부분에 대한 공부가 더 필요하다. 적절히 공부하여 정답을 찾도록 하자.
'Programming Language > basic C Language' 카테고리의 다른 글
8. 구조체 (0) | 2018.10.06 |
---|---|
4. 제어문 - if, else if, else, switch~case (0) | 2018.10.06 |
3. 배열, 반복문 (while, for, do~while) (0) | 2018.10.06 |
2. 변수와 자료형과 연산자 (0) | 2018.10.06 |
1. 표준입출력 (0) | 2018.10.06 |