함수 (Function)
인자와 매개변수(Argument and Parameter)
함수를 호출할 때 넘기는 값은 인자(argument)라고 부르고, 함수 정의에서 그 값을 받는 변수는 매개변수(parameter)라고 부른다.
int add(int x, int y)
{
return x + y;
}
int main(void)
{
int result = add(1, 2);
return result;
}
위 코드에서 1, 2는 인자(argument)이고, x, y는 매개변수(parameter)다.
실제로는 두 용어가 엄격히 구분되지 않고 혼용되는 경우도 많다.
그래서 문맥상 어떤 뜻으로 쓰였는지 판단하면 된다.
함수 시그니처(Function Signature)
signature는 라틴어 signum에서 유래했다.
signum은 표식, 표시, 기호, 징표라는 뜻이다.
signature는 문서에 남기는 자기 고유의 표시, 즉 서명을 뜻한다.
함수 시그니처는 어떤 함수를 다른 함수와 구별하게 해 주는 형태를 뜻한다.
다만 시그니처에 무엇까지 포함되는지는 프로그래밍 언어의 규칙에 따라 다르다.
C는 함수 오버로딩을 지원하지 않는다.
같은 이름의 함수를 매개변수 타입만 다르게 해서 여러 개 만들 수 없다.
int add(int x, int y);
double add(double x, double y); // error: conflicting types for 'add'
반면 Java에서는 클래스 안에 정의된 함수인 메서드를 오버로딩할 수 있다.
Java에서는 메서드 이름과 매개변수 타입 목록이 같아야 같은 시그니처로 본다.
int average(int... values);
double average(double... values);
위 두 메서드는 이름은 같지만 매개변수 타입이 다르므로 서로 다른 시그니처를 가진다.
함수는 블랙박스다
블랙박스는 내부 구조를 몰라도 입력과 출력만으로 다룰 수 있는 장치나 시스템을 뜻한다.
함수도 같은 방식으로 바라볼 수 있다.
함수는 하나의 계약이다.
함수 이름은 계약의 이름이고, 매개변수는 입력이며, 반환값은 출력이다.
호출자는 계약에 맞는 값을 넘기고, 함수는 그에 맞는 결과를 돌려준다.
따라서 호출자는 함수가 내부에서 어떻게 동작하는지 몰라도 된다.
올바른 값을 넘기면 올바른 결과가 돌아온다는 사실만 믿고 사용하면 된다.
반대로 함수도 자신을 호출한 쪽의 맥락을 알 필요가 없다.
그저 약속된 입력을 받아 약속된 출력을 반환하면 된다.
그래서 함수 이름은 중요하다.
호출자가 함수 내부를 읽지 않아도, 이름만 보고 어떤 계약인지 짐작할 수 있어야 한다.
문제는 함수를 작성하는 사람과 호출하는 사람이 같을 때 생긴다.
맥락을 이미 알고 있다는 이유로 함수에 불필요한 일을 넣기 쉽다.
나쁜 예제
배열에서 최댓값을 찾아 반환하는 find_max 함수를 생각해 보자.
이름만 보면 최댓값을 찾아서 돌려주는 함수처럼 보인다.
그런데 함수 안에서 출력까지 해 버리면 어떻게 될까?
#include <stdio.h>
int find_max(int arr[], int size)
{
int max = arr[0];
for (int i = 1; i < size; i++)
{
if (arr[i] > max)
{
max = arr[i];
}
}
printf("Max: %d\n", max);
return max;
}
int main(void)
{
int data[] = {1, 2, 3, 4, 5};
int result = find_max(data, 5);
return 0;
}
이런 코드는 보통 다음과 같은 생각에서 나온다.
“어차피 이 함수 내가 직접 호출할 거고, 그 맥락 내가 다 알잖아. 그냥 함수 안에서 출력까지 처리해 버리지 뭐.”
하지만 이 선택은 함수의 역할을 흐린다.
find_max라는 이름은 “최댓값을 찾는다”는 동작을 보여 준다.
그런데 실제 함수는 최댓값을 찾는 일뿐 아니라 콘솔에 출력하는 일까지 한다.
이 문제는 시간이 지나면 더 크게 드러난다.
처음 작성할 때 알고 있던 맥락은 쉽게 사라진다.
나중의 나는 지금의 의도를 기억하지 못할 수 있고, 다른 사람이 이 코드를 유지보수할 수도 있다.
결국 find_max는 순수한 계산용 함수로 믿고 사용할 수 없게 된다.
나중에 다른 누군가가 이 문제를 발견하고 출력 코드를 제거하려 해도, 이미 여러 호출 지점이 “최댓값을 찾으면 출력도 한다”고 가정한 채 작성되어 있었다.
좋은 예제
계산과 출력을 분리한다.
find_max는 최댓값을 찾기만 하고, 그 값을 어떻게 사용할지는 호출자가 결정한다.
#include <stdio.h>
int find_max(int arr[], int size)
{
int max = arr[0];
for (int i = 1; i < size; i++)
{
if (arr[i] > max)
{
max = arr[i];
}
}
return max;
}
int main(void)
{
int data[] = {1, 2, 3, 4, 5};
int result = find_max(data, 5);
printf("Max: %d\n", result);
return 0;
}
이제 find_max는 이름 그대로 최댓값을 찾는 책임만 가진다.
출력은 호출자의 몫이다.
함수가 입력을 받아 약속된 출력을 돌려주는 블랙박스로 남을수록 이해하기 쉽고, 테스트하기 쉽고, 재사용하기 쉽다.
선조건(Precondition)
선조건은 함수 실행 전에 참이라고 가정되는 조건이다.
divide 함수를 보자.
이 함수는 두 정수를 나눈 몫을 반환한다.
int divide(int dividend, int divisor)
{
return dividend / divisor;
}
divisor가 0이면 나눗셈을 할 수 있을까?
0으로 나누는 것은 수학적으로 정의되지 않으므로, 이 함수는 정상적인 결과를 반환할 수 없다.
그렇다면 매개변수로 0이 들어올 수 있을까?
이 가능성을 어떻게 바라보느냐에 따라 함수의 선조건이 달라지고, 오류를 처리하는 방식도 달라진다.
먼저 divide는 0이 아닌 값으로만 나눈다고 정해 보자.
그러면 이 함수의 선조건은 divisor != 0이다.
이 조건을 어기고 호출했다면 함수의 잘못이 아니라, 호출자의 잘못이다.
호출자가 함수의 계약을 어긴 것이다.
이런 경우에는 보통 assert로 검사한다.
#include <assert.h>
int divide(int dividend, int divisor)
{
assert(divisor != 0);
return dividend / divisor;
}
assert는 조건이 참이라고 주장하는 코드다.
조건이 참이면 아무 일도 일어나지 않고, 거짓이면 오류를 발생시킨다.
assert는 보통 디버그 빌드에서는 작동하고, 릴리스 빌드에서는 작동하지 않는다.
따라서 assert는 프로그래머가 잘못 호출한 상황을 개발 중에 빨리 발견하는 데 적합하다.
반면 사용자 입력, 파일, 네트워크, 외부 API처럼 실패가 자연스럽게 발생할 수 있는 값이라면 선조건이 달라진다.
그런 경우에는 실패할 수 있음을 함수 설계에 포함해야 한다.
예를 들어 divisor가 사용자 입력에서 온 값이라면 0이 들어올 수 있다.
이럴 때는 함수가 실패할 수 있음을 try 형태의 함수로 작성해 볼 수 있다.
#include <stdbool.h>
bool try_divide(int dividend, int divisor, int *out_result)
{
if (divisor == 0 || out_result == NULL)
{
return false;
}
*out_result = dividend / divisor;
return true;
}
이 함수는 실패할 수 있음을 이름과 반환값으로 드러낸다.
호출자는 false가 반환될 가능성을 보고 그에 맞는 처리를 할 수 있다.
핵심은 입력값을 프로그래머가 통제할 수 있는지 보는 것이다.
프로그래머가 divisor에 0을 넘기지 않도록 통제할 수 있다면, divisor != 0은 선조건으로 두는 편이 자연스럽다.
반대로 외부 입력 때문에 0이 들어올 수 있다면, 실패 가능성이 있는 함수로 설계해야 한다.
후조건(Postcondition)
후조건은 함수 실행이 끝난 뒤 참이라고 보장되는 조건이다.
다만 이 보장은 선조건이 만족된 호출에 대해서만 성립한다.
선조건이 만족되었음에도 후조건이 지켜지지 않았다면, 이는 함수 내부 로직에 버그가 있다는 뜻이다.
예를 들어 절댓값을 반환하는 함수를 생각해 보자.
int absolute(int x)
{
if (x < 0)
{
return -x;
}
return x;
}
이 함수가 의도한 후조건은 다음과 같다.
- 반환값은 0 이상이다.
- 반환값은 매개변수
x의 절댓값이다.
하지만 이 후조건이 모든 int 값에 대해 항상 보장되는 것은 아니다.
후조건이 성립하려면 절댓값도 int 범위 안에서 표현할 수 있어야 한다.
2의 보수를 사용하는 32비트 int의 범위는 INT_MIN(-2,147,483,648) ~ INT_MAX(2,147,483,647)이다.
이 범위는 음수 쪽이 하나 더 넓다.
absolute는 음수일 때 -x를 반환하므로, x가 INT_MIN이면 2,147,483,648을 만들어야 한다.
하지만 이 값은 INT_MAX보다 1 크기 때문에 int 범위로 표현할 수 없다.
결국 x == INT_MIN일 때는 위의 후조건을 보장할 수 없다.
32비트 2의 보수 표현에서 INT_MIN의 비트 패턴은 다음과 같다.
1000 0000 0000 0000 0000 0000 0000 0000
-x를 한다는 것은 2의 보수 시스템에서 비트를 뒤집고(NOT), 1을 더하는 방식이다.
그런데 INT_MIN에 이 연산을 적용하면 다음처럼 다시 같은 비트 패턴이 된다.
x 1000 0000 0000 0000 0000 0000 0000 0000
NOT 0111 1111 1111 1111 1111 1111 1111 1111
+1 1000 0000 0000 0000 0000 0000 0000 0000
C 언어에서 signed integer overflow는 정의되지 않은 동작(undefined behavior)이다.
다만 2의 보수를 사용하는 대부분의 환경에서는, 이 UB가 위 비트 연산처럼 동작하여 INT_MIN을 넣었을 때 INT_MIN이 그대로 반환되는 모습을 관찰할 수 있다.
따라서 이 함수가 “반환값은 0 이상이고, 매개변수 x의 절댓값이다”라는 후조건을 보장하려면 x != INT_MIN이라는 선조건이 필요하다.