24 함수 선언과 정의
먼저 알아야 할 것
돌아보기
16장에서 “컴파일 단계는 선언(예고)만 있으면 만족하고, 몸통을 찾아 잇는 것은 링커”라고 했다. 그때의 “선언”과, 23장에서 배운 “변수 선언”은 같은 낱말을 쓴다 — 우연인가?
답. 우연이 아니다. C에서 선언이란 언제나 같은 일 — “컴파일러에게 이름과 그 생김새(타입)를 미리 알리는 것” — 이다. 변수 선언은 값의 이름을, 함수 선언은 일의 이름을 알린다. 이 장에서 그 두 번째 종류를 만들고 나면, #include가 가져오던 것의 정체(선언들의 묶음)까지 한 줄로 꿰어진다.
이 장의 필요성과 맥락
int main(void) 의 외상이 갚아진다.이 장이 끝나면
return, 스코프의 첫걸음까지. 그리고 이 장의 끝에서, 15장부터 끌고 온 외상 — int main(void)와 return 0 — 을 완전히 청산한다.이 장에서 답할 질문
- 그러면 그냥 전부
static inline으로 쓰면 되지 않는가? main도 함수라면, 우리가main()이라고 직접 불러도 되는가?
24.1 정의 — 일꾼을 만드는 문법
21장의 abs나 printf 같은 일꾼을 이제 직접 만든다. 함수 정의의 문법은 이렇다:
반환타입 이름(매개변수 목록)
{
몸통 — 문장들의 목록
}시연부터 보면 빠르다. 정수를 받아 그 제곱을 돌려주는 일꾼이다:
examples/ch24/fn.c
#include <stdio.h>
int square(int n) /* 정수 하나를 받아 정수 하나를 돌려주는 일꾼 */
{
return n * n;
}
int main(void)
{
printf("%d\n", square(12));
return 0;
}
실행 결과
144
square의 정의를 부분별로 읽는다.
- 반환타입
int— 이 일꾼이 내놓는 결과가 정수라는 약속이다. - 매개변수
int n— 재료를 받는 변수 선언이다(23장의 문법 그대로). 호출될 때, 재료의 값이 이 변수에 담긴 채 몸통이 시작된다.square(12)라 부르면n에 12가 담기고 몸통이 돈다. return문 — 결과를 내놓고 즉시 퇴장한다.return n * n;은 수식을 평가해 그 값을 호출한 쪽에 돌려준다 — 21장에서 배운 “호출 자리가 반환값으로 바뀐다”의 공급자 쪽 모습이다.
이것으로 호출의 그림이 완성됐다. 21장은 소비자의 눈(부르기)이었고 이 장은 생산자의 눈(만들기)이다 — printf("%d\n", square(12))에서 값 12가 n으로, n * n의 144가 호출 자리로, 다시 printf의 재료로 흐르는 릴레이 전체가 이제 보인다.
24.2 선언 — 계약 서명만 미리
위 예제에서 square의 정의는 main보다 위에 있다. 우연이 아니다 — 컴파일러는 파일을 위에서 아래로 읽으므로(16장), main 안의 square(12)를 만나는 시점에 square가 무엇인지(재료와 결과의 타입)를 이미 알아야 호출이 맞는지 검사할 수 있다.
그런데 정의 전체를 미리 보여 줄 필요는 없다. 계약 서명만 미리 알리면 된다 — 그것이 함수 선언, 관용적으로 원형(prototype)이다:
int square(int n); /* 선언: 이런 일꾼이 (어딘가에) 있다 */몸통 없이 서명과 세미콜론뿐이다. 파일 위쪽에 원형을 적어 두면 정의는 아래 어디에 있어도 되고 — 더 중요하게는 — 다른 파일에 있어도 된다. 16장의 수수께끼가 여기서 완전히 풀린다: #include <stdio.h>가 붙여 넣던 수만 줄의 정체가 바로 이런 원형들의 묶음이다. printf의 계약 서명을 컴파일러에게 미리 보여 주는 것 — 그것이 헤더 파일의 일이고, 몸통은 링커가 잇는다(정의와 선언의 분업이 여러 파일 프로젝트로 자라는 이야기는 54장이다).
이 원형이라는 발명품의 나이도 이제 말할 수 있다 — 12장에서 본 대로, C89가 표준화하며 들여온 것이다. 그전의 C는 재료의 타입을 검사하지 않았고, 틀린 재료로 부른 호출이 소리 없이 통과해 사고가 났다. 원형은 “계약을 컴파일 시점에 검사한다”는, C가 계약의 언어로 자라는 첫걸음 이었다.
실제 사례. 원형 없는 호출이 남긴 상처 — 암묵 선언
원형이 없던 시절(12장)의 C에는 관대한 규칙이 하나 있었다. 선언한 적 없는 이름을 함수처럼 부르면, 컴파일러가 “정수를 돌려주는 함수겠거니” 하고 멋대로 선언을 지어내 통과시킨 것이다(암묵 선언). 편해 보이지만 결과는 참혹했다 — 실제로는 실수를 돌려주는 함수를 정수 함수로 오해하거나, 포인터를 받는 함수에 정수를 넘겨도 아무 경고 없이 컴파일 됐다. 특히 정수와 포인터의 크기가 다른 기계로 옮길 때 조용히 무너지는 코드가 쏟아졌다 — 32비트에서 64비트로 넘어가던 시기에 이 부류의 버그가 대거 드러났다. C99가 마침내 암묵 선언을 표준에서 제거했고, 오늘의 컴파일러는 이를 오류로 잡는다. “쓰기 전에 선언한다”는 규칙이 성가신 격식이 아니라 사고의 방벽이라는 것 — 반세기의 상처가 남긴 교훈이다.24.3 선언에 붙는 낱말 하나 — inline
선언 앞자리에 올 수 있는 낱말 중 함수에만 붙는 것이 둘 있다. 표준은 이것을 함수 지정자(function specifier)라 부르고, 목록은 inline 과 _Noreturn 둘뿐이다. _Noreturn 은 C23 에서 물러났으니(대신 [[noreturn]] 속성을 쓴다) 실질적으로 하나다.
examples/ch24/inline_fn.c
/* 함수 지정자 inline — 무엇을 약속하고 무엇을 약속하지 않는가.
실무에서 통하는 두 형태만 쓰고, 나머지는 주석으로 이유를 남긴다. */
#include <stdio.h>
/* ① 한 파일 안에서 쓰는 도우미 — 이 형태가 가장 안전하다.
static 이므로 이 번역 단위의 정의이고, 외부 정의를 따로 둘 필요가 없다. */
static inline int square(int x) { return x * x; }
/* ② 헤더에 두고 여러 파일에서 쓰는 형태.
아래 두 줄이 짝이다 — inline 정의 하나 + extern 선언 하나.
extern 선언이 "이 번역 단위가 외부 정의를 낸다"고 말해 준다. */
inline int cube(int x) { return x * x * x; }
extern int cube(int x);
int main(void)
{
printf("① static inline square(5) = %d\n", square(5));
printf("② inline + extern cube(3) = %d\n", cube(3));
/* ③ 주소를 얻을 수 있다 — 함수는 함수다.
다만 주소를 얻는 순간 「어딘가에 실물이 하나 있어야」 한다. */
int (*f)(int) = square;
int (*g)(int) = cube;
printf("③ calling through a pointer: f(6)=%d g(2)=%d\n", f(6), g(2));
/* ④ inline 은 「이렇게 해 달라」는 부탁일 뿐, 약속이 아니다.
컴파일러는 펼칠 수도 있고 안 펼칠 수도 있다. */
printf("④ same answer, inlined or not: %d %d\n", square(4), f(4));
return 0;
}
실행 결과
① static inline square(5) = 25
② inline + extern cube(3) = 27
③ calling through a pointer: f(6)=36 g(2)=8
④ same answer, inlined or not: 16 16
24.3.1 inline 은 명령이 아니라 부탁이다
먼저 흔한 오해부터 걷어 낸다.
흔한 오해. “inline 을 붙이면 함수 호출이 사라진다”
아니다. 표준의 표현은 “가능한 한 빠르게 되도록 한다” 는 취지의 권고이고, 구현이 그 권고를 무시해도 적합하다고 못박아 두었다. 실제로 오늘의 컴파일러는 inline 이 붙지 않은 함수도 이득이 되면 펼치고, 붙은 함수도 크거나 이득이 없으면 펼치지 않는다.
★ 그래서 inline 의 실제 값어치는 「빠르게 해 달라」가 아니라 「이 정의가 여러 번역 단위에 나타나도 좋다」는 허가에 있다. 그 허가가 없으면 헤더에 함수 몸통을 둘 수 없다 — 두 파일이 그 헤더를 포함하는 순간 링커가 중복 정의로 잡기 때문이다(54장).
펼침을 정말로 강제하고 싶다면 표준 밖으로 나가야 한다 — __attribute__((always_inline))(GCC·Clang)나 __forceinline(MSVC)이고, 그때는 platform 상자에 적어 둘 일이다.
24.3.2 주소를 얻을 수 있다 — 그리고 그 순간 실물이 필요해진다
★ 자주 나오는 물음에 먼저 답한다. inline 함수의 주소는 얻을 수 있다. inline 은 타입의 일부가 아니라 그 선언에 붙는 성질이므로, 함수 포인터에 담는 데 아무 제약이 없다. 시연 ③이 그것이다.
그런데 여기서 C 의 inline 이 왜 까다롭다는 소리를 듣는지가 드러난다. 주소를 얻는다는 것은 어딘가에 실물(외부 정의)이 하나 있어야 한다는 뜻이다. 그리고 inline 정의만으로는 외부 정의가 생기지 않는다.
24.3.3 C 의 inline 모형 — 규칙 한 줄과 그 파장
규칙을 정확히 적으면 이렇다.
> 어떤 파일 안에서 함수를 inline 으로 선언했고, 그 파일의 모든 파일 수준 선언에 > extern 이 없다면, 그 파일의 정의는 인라인 정의다. 인라인 정의는 외부 정의를 > 제공하지 않는다.
파장이 고약하다. 실제로 재 보면 이렇게 갈린다.
| 무엇을 했나 | -O0 | -O2 |
|---|---|---|
inline 정의만 두고 부르기 | ★ undefined reference to 'add' | 링크 성공 |
| 주소가 실제로 새어 나가게 하기 | 실패 | ★ 실패 — undefined reference |
inline 정의 + extern 선언 | 성공 | 성공 |
static inline | 성공 | 성공 |
표 24.1
★ 첫 줄을 눈여겨보라. 최적화를 켜면 링크되고, 끄면 깨진다. 이 책이 여러 번 본 “릴리스에서만 나는 버그”의 정반대 방향이다. 이유는 단순하다 — -O2 는 호출을 전부 펼쳐 버려 실물을 찾을 일이 없었고, -O0 은 진짜로 부르려다 없다는 것을 알아챈 것이다. 디버그 빌드에서만 링크가 깨지는 프로젝트의 흔한 원인이 이것이다.
24.3.4 그래서 실무에서는 두 형태만 쓴다
| 형태 | 어디에 두나 | 언제 |
|---|---|---|
static inline | 헤더 또는 소스 | ★ 대부분의 경우. 번역 단위마다 제 사본을 갖고, 외부 정의를 따로 둘 필요가 없다 |
inline 정의 + 한 곳의 extern 선언 | 정의는 헤더, extern 선언은 소스 하나 | 여러 파일이 쓰고, 실물이 하나여야 할 때(주소 비교 등) |
표 24.2
둘째 형태의 짝을 코드로 적으면 이렇다.
/* util.h — 여기에 정의를 둔다 */
inline int cube(int x) { return x * x * x; }
/* util.c — 여기에 이 한 줄. 이 번역 단위가 외부 정의를 낸다 */
#include "util.h"
extern int cube(int x);시연은 한 파일 안에서 그 짝을 보이느라 두 줄을 붙여 두었지만, 실제 배치는 위와 같다.
반례. 헤더에 inline 정의만 두고 끝내기
/* util.h */
inline int cube(int x) { return x * x * x; } /* 이것뿐이다 */어느 소스에도 extern 선언이 없으면 프로그램 전체에 외부 정의가 없다. 그런데 대개의 빌드에서는 잘 돌아간다 — 최적화기가 전부 펼쳐 버리기 때문이다. 그러다 디버그 빌드를 하거나, 누군가 함수 포인터에 담는 순간 undefined reference 가 난다.
★ 이 결함의 성질이 나쁘다. 코드를 고치지 않았는데 빌드 설정만 바꾸면 드러난다. 처방은 둘 중 하나다 — 여러 파일에서 쓸 것이 아니면 static inline 으로 바꾸고, 써야 한다면 소스 하나에 extern 선언을 반드시 둔다.
24.3.5 인라인 정의에 붙는 두 가지 제한
static 이 아닌 인라인 정의에는 표준이 제한을 둔다. 정의가 여러 번역 단위에 나타날 수 있으니, 번역 단위마다 달라질 수 있는 것에 기대면 안 되기 때문이다.
| 금지 | 왜 |
|---|---|
| 수정 가능한 정적·스레드 저장 기간 객체를 정의하는 것 | 번역 단위마다 따로 생기면 “그 카운터”가 여러 개가 된다 |
내부 연결(static)을 가진 이름을 참조하는 것 | 다른 번역 단위에는 그 이름이 아예 없다 |
표 24.3
실측하면 GCC 가 이렇게 말한다.
warning: 'n' is static but declared in inline function 'counter' which is not static
warning: 'secret' is static but used in inline function 'peek' which is not static★ 기본값이 경고라는 점을 눈여겨보라. -pedantic-errors 를 주면 오류로 올라간다. 읽기 전용은 괜찮다 — static const 상수를 참조하는 것은 허용된다. 그리고 이 제한들은 static inline 에는 붙지 않는다. 애초에 그 번역 단위의 것이니까.
문. 그러면 그냥 전부 static inline 으로 쓰면 되지 않는가?
답. 대부분 그렇게 하면 되고, 이 책도 그쪽을 기본으로 권한다. 값을 치르는 자리는 둘이다.
첫째, 사본이 늘어난다. 펼쳐지지 않은 번역 단위마다 제 사본이 생기므로 코드가 커질 수 있다. 작은 함수라면 대개 무시할 만하다.
둘째, 주소가 하나가 아니다. 번역 단위마다 다른 실물이므로 함수 포인터를 서로 비교하면 다르게 나올 수 있다. 콜백을 등록해 두었다가 같은 것인지 견주는 코드라면 이 차이가 버그가 된다. 그럴 때가 둘째 형태(inline + extern)를 쓸 자리다.
24.4 스코프 — 이름이 보이는 범위
square의 n과 main의 어떤 이름이 부딪히지 않을까 — 걱정할 필요가 없다. 블록 안에서 선언된 이름은 그 블록 안에서만 보인다. 이 보이는 범위를 스코프(scope)라 한다. n은 square의 몸통에서만 존재하고, main은 그 이름을 아예 모른다. 함수마다 자기만의 작업대가 있는 셈이라, 일꾼들이 서로의 도구를 건드릴 걱정 없이 이름을 자유로이 짓는다 — 프로그램이 커져도 무너지지 않는 비결의 절반이 이 칸막이다. (작업대의 더 깊은 사정 — 이름의 수명, 함수 사이에 값이 어떻게 오가는지의 정밀한 그림 — 은 30·44장에서 이어진다. 그리고 이름에는 스코프 말고도 두 성질이 더 있다 — 연결과 이름 공간으로, 54·55장에서 셋을 나란히 놓는다.)
24.5 외상 청산 — int main(void)
이제 이 순간을 위해 아껴 둔 일을 한다. 15장의 헬로 월드에서 마지막까지 “주문”으로 남겨 둔 두 줄을, 오늘 배운 문법으로 다시 읽는다.
int main(void)
{
...
return 0;
}이것은 그냥 함수 정의다. 이름이 main이고, 매개변수 목록의 void는 “재료 없음”을 명시하는 표기이며, 반환타입은 int — 즉 “재료 없이 일하고 정수 하나를 내놓는 일꾼”이다. 특별한 것은 문법이 아니라 호출자 다: main을 부르는 것은 우리 코드가 아니라 운영체제다. 프로그램 실행이란 운영체제가 main을 호출하는 일이고, return 0;은 그 호출자에게 결과값을 돌려주는 것이다 — 관례로 0이 “무사히 마쳤음”, 0 아닌 값이 “문제 있었음”이다. 그 값은 버려지지 않는다 — 운영체제와 셸이 받아서, 프로그램들을 이어 붙인 자동화가 성공·실패를 가리는 데 쓴다(10장의 스트림처럼, 이것도 프로그램을 부품으로 잇는 장치다).
15장의 외상 장부가 이것으로 전부 청산되었다. 헬로 월드의 여섯 줄에 이제 모르는 것이 없다 — 전처리 지시, 함수 정의, 블록, 호출 수식문, 문자열 리터럴, 서식, 반환까지. 입문의 첫 산 하나를 넘은 것이다.
문. main도 함수라면, 우리가 main()이라고 직접 불러도 되는가?
답. 문법상 가능하지만 하지 않는 것이 관행이고, 할 이유도 없다 — main은 “운영체제가 부르는 입구”라는 역할이 본질이라서, 코드 안에서 다시 부르는 것은 건물의 정문을 방 안에 또 만드는 격이다. 재사용하고 싶은 일이 있다면 그 일을 별도 함수로 빼서 부르면 된다 — 사실 그것이 함수를 만드는 이유의 전부다: 이름 붙일 가치가 있는 일에 이름을 붙이는 것.
이름 붙은 값(23장)과 이름 붙은 일(24장)을 다 갖췄다. 이제 22장에서 미뤄 둔 마지막 약속을 지킬 수 있다 — 값을 담을 곳이 생겼으니, 드디어 입력이다.