33 함수의 의미 — 값 복사와 부수효과
먼저 알아야 할 것
돌아보기
24장에서 매개변수는 “재료를 받는 변수 선언”이라 했다. 그러면 함수 안에서 그 매개변수를 바꾸면 — 밖에서 재료로 넘긴 변수도 바뀌는가?
답. 바뀌지 않는다 — 그리고 이것이 C 함수의 의미를 정하는 단 하나의 규칙이다: 값은 복사되어 건너간다. 매개변수는 재료의 값을 복사받은 별개의 변수이고, 함수 안의 어떤 일도 원본에 닿지 않는다. 말보다 증명이 빠르다 — 바로 시연한다.
이 장의 필요성과 맥락
이 장이 끝나면
?:까지. 마지막에 심는 씨앗 — 계약이라는 관점 — 은 제9부에서 꽃핀다.이 장에서 답할 질문
- 값 복사가 규칙이라면, 큰 구조체를 넘길 때마다 통째로 복사되는가 — 비용이 만만치 않을 텐데.
33.1 값 복사 — 원본은 안전하다
examples/ch33/copy.c
#include <stdio.h>
void try_change(int x)
{
x = 999; /* 복사본을 바꿀 뿐이다 */
printf("inside function, x = %d\n", x);
}
int main(void)
{
int n = 1;
try_change(n);
printf("after the call, n = %d\n", n); /* 원본은 그대로다 */
return 0;
}
실행 결과
inside function, x = 999
after the call, n = 1
try_change 안에서 x를 999로 바꿨지만, 밖의 n은 1 그대로다. 호출 try_change(n)의 정확한 의미가 이것이다 — n의 값 1이 복사되어 x라는 다른 변수(variable)의 첫 값이 되고, 이후 둘은 남남이다. 24장의 스코프 칸막이에 이 복사 규칙까지 더하면, 함수는 완전한 격리실이 된다: 재료는 값으로 들어오고, 결과는 return의 값으로 나가며, 그 외의 통로는 없다.
흔한 오해. “함수에 변수를 넘긴다”
일상어로는 자연스럽지만, C의 의미론으로는 틀린 문장이고 — 이 문장 대로 상상하는 순간 위 시연이 수수께끼가 된다. 넘어가는 것은 변수가 아니라 변수의 값(복사본)이다. “그러면 함수가 밖의 변수를 바꿔 주는 일은 아예 불가능한가?”라는 자연스러운 반문이 생기는데 — 가능하게 만드는 정공법이 따로 있다: 변수 대신 변수의 주소를 값으로 복사해 넘기는 것이다. 주소를 받은 함수는 그 주소로 찾아가 원본을 만진다. 25장에서sscanf(line, "%d", &n)의 &가 정확히 이 일을 하고 있었다 — 값 복사 규칙은 그대로인 채(주소도 값이다 — 5장!), 효과는 “원본 수정”이 되는 것. 이 정공법의 문법이 35장의 포인터다.33.2 “값 호출”과 “참조 호출” — 용어를 못박는다
입문서마다 지면을 크게 할애하는 주제가 있다. call by value와 call by reference다. 결론부터 적는다.
C에는 참조 호출이 없다. C의 인자 전달은 언제나 값의 복사다.
포인터를 넘기는 것도 예외가 아니다. f(&n)이 하는 일은 “n을 넘기는 것”이 아니라 n의 주소라는 값을 복사해 넘기는 것이다. 함수 안의 매개변수는 그 주소를 담은 별개의 변수이고, 그 변수에 다른 주소를 대입해도 호출자 쪽은 아무 영향을 받지 않는다. 다만 그 주소를 따라가서(역참조) 쓰면 호출자가 가진 객체가 바뀔 뿐이다. 바뀌는 것은 인자가 아니라 인자가 가리킨 곳이다.
흔한 오해. “포인터를 넘기면 참조 호출이다”
많은 교재가 이 표현을 쓰는데, 정확하지 않다. 참조 호출은 언어가 “호출자의 변수 자체를 매개변수에 묶어 주는” 기능을 가질 때 쓰는 말이다 — C++의 참조(void f(int &x))나 파스칼의 var 매개변수가 그것이다. 그런 언어에서는 함수 안에서 x = 5라고만 써도 호출자의 변수가 바뀐다.
C에는 그런 문법이 없다. void f(int *p)에 *p = 5라고 역참조를 적어야 하고, 그 별표가 바로 “이것은 값(주소)이고, 나는 지금 그것을 따라간다”는 증거다. 그래서 정확한 표현은 “포인터를 값으로 넘겨 참조와 같은 효과를 낸다”이지 “참조 호출”이 아니다. 영어권에서도 이 구분은 오래된 논쟁거리인데, 표준의 용어로 보면 다툴 여지가 없다 — C 표준에는 참조라는 개념 자체가 없다.
용어를 정확히 하면 실제로 헷갈릴 일이 줄어든다. 자주 나오는 세 물음이 같은 규칙 하나로 풀린다.
- 배열을 넘기면 복사가 안 되는 것 아닌가? — 배열 이름이 첫 원소의 주소로 무너져(38장) 그 주소가 복사되어 간다. 규칙의 예외가 아니라 적용이다.
- 구조체를 넘기면 통째로 복사되나? — 그렇다(46장). 크면 포인터를 넘기는 관행이 생긴 이유다.
- 함수가 호출자의 포인터 자체를 바꾸게 하려면? — 포인터의 주소를 넘긴다(
int *). 값 복사 규칙을 알면 자연스럽게 나오는 답이다.
33.3 부수효과와 평가 순서 — 씨앗의 회수
20장에서 “한 문장 안의 계산 시간 순서는 달라질 수 있다”는 씨앗만 심어 두었다. 이제 재료가 다 모였으니 정식으로 정리한다.
부수효과(side effect)란 수식 평가가 값을 내놓는 것 외에 세상의 상태를 바꾸는 모든 일이다 — 우리가 아는 것만 벌써 셋이다: 출력(22장), 대입과 +=·++(20·32장), 그리고 입력(25장). 함수 호출은 몸통이 이런 일을 하면 부수효과를 품는다.
평가 순서의 규칙은 두 문장으로 요약된다. 첫째, 한 문장(정확히는 완전 수식) 안에서 부분 수식들의 평가 순서는 대부분 미지정이다 — 함수 호출 둘이 한 수식에 있으면 어느 쪽이 먼저 불릴지 계약에 없다(20장의 “호랑이/사자”). 둘째, 문장의 끝(세미콜론)에 도달하는 순간, 그 문장이 일으킨 부수효과는 전부 완료됨이 보장된다 — 이 보장 지점을 전통 용어로 시퀀스 포인트(sequence point)라 한다. 세미콜론 외에도 몇 곳이 더 있는데, 대표가 30장에서 만난 &&·||(왼쪽 평가 완료 후 오른쪽)와 함수 호출의 경계(재료 평가 완료 후 몸통 진입)다. 현대 표준(C11 이후)은 같은 개념을 “앞서 차례 매겨짐”(sequenced before)이라는 관계로 더 정밀하게 다듬었지만, 고전 용어가 여전히 통용된다.
이 규칙에서 실전 수칙과 함정이 나온다. 수칙은 20장 그대로 — 순서가 중요한 부수효과들은 문장을 나눠라. 함정은 새로 생겼다 — ++를 배웠기 때문이다. 한 수식 안에서 같은 변수를 두 번 바꾸거나, 바꾸면서 따로 읽는 코드:
i = i++ + 1; /* 같은 i를 두 곳에서 바꾼다 — 계약 밖 */
printf("%d %d\n", i, i++); /* 바꾸면서 따로 읽는다 — 계약 밖 */이런 수식은 미지정(어느 순서든 하나가 일어남)을 넘어 아예 정의되지 않은 동작이다 — 표준은 결과를 전혀 보장하지 않는다(52장). 규칙을 외울 것 없이 무늬만 기억하면 된다: 한 문장에서 한 변수는 한 번만 바꾼다. 17장의 UBSan과 컴파일러 경고가 이 무늬를 잘 잡아 주는 것도 든든한 뒷배다.
문. 값 복사가 규칙이라면, 큰 구조체를 넘길 때마다 통째로 복사되는가 — 비용이 만만치 않을 텐데.
답. 그렇다, 의미론상으로는 복사다 — 그리고 그 비용이 실무에서 포인터로 넘기는 관행의 이유가 된다(46장에서 구조체와 함께 다룬다). 다만 두 가지 단서가 있다. 첫째, 작은 값은 복사가 사실상 공짜다 — 11장에서 배운 대로 인자는 대개 레지스터로 건너가므로, 정수 몇 개를 넘기는 데는 메모리 왕복조차 없다. 둘째, 13장의 편집자가 개입한다 — 관찰 가능한 결과가 같다면 실제 복사를 생략하기도 한다. 의미는 복사, 구현은 컴파일러의 재량 — 이 구별이 성능을 걱정하기 전에 먼저 잡아야 할 관점이다.
33.4 재귀 — 자신을 부르는 함수
함수가 함수를 부를 수 있다면 — 자기 자신도 부를 수 있는가. 있다. 재귀(recursion)다.
examples/ch33/fact.c
#include <stdio.h>
int fact(int n)
{
if (n <= 1) {
return 1; /* 바닥: 더 내려가지 않는 경우 */
}
return n * fact(n - 1); /* 자신을 부르되, 더 작은 문제로 */
}
int main(void)
{
printf("5! = %d\n", fact(5));
return 0;
}
실행 결과
5! = 120
fact(5)는 5 * fact(4)이고, 그 안은 4 * fact(3)… 바닥(n <= 1) 에 닿으면 1을 돌려주며 겹겹의 호출이 차례로 값을 물고 돌아온다. 이것이 가능한 이유는 이 장의 규칙 그 자체다 — 호출마다 매개변수 n이 새로 복사되므로, 다섯 겹의 fact는 각자의 n(5, 4, 3, 2, 1)을 가진 다섯 개의 격리실이다. 값 복사와 스코프가 만드는 격리가 없다면 재귀는 성립 하지 않는다.
재귀는 “더 작은 같은 문제”로 쪼개지는 일에 자연스러운 표현이고(나무 구조 탐색이 대표 — 46장 이후), 모든 재귀는 루프로도 적을 수 있다 (32장의 팩토리얼 루프판을 떠올리면 된다). 어느 쪽이 좋은가는 문제의 모양이 정한다 — 그리고 재귀에는 “겹겹의 호출”이 쌓이는 물리적 공간 문제가 따라오는데, 그 공간(스택)의 정체는 44장에서 만난다.
33.5 마지막 연산자, 그리고 계약이라는 씨앗
이 부의 배분표 마지막 항목 — 조건 연산자 ?:다. 조건 ? 값1 : 값2는 “조건이 참이면 값1, 거짓이면 값2”가 되는 수식 이다. int big = a > b ? a : b;처럼 “골라 담기”를 한 줄로 적는다 — if는 문장이라 값이 될 수 없는 자리(초기화 등)에서 요긴하다. 남용하면 읽기 어려워지므로, 이 책은 “한 겹까지만”을 관행으로 삼는다.
세 가지 성질을 알아 두면 충분하다.
첫째, 고르지 않은 쪽은 평가되지 않는다. 30장의 단락 평가와 같은 보장이다 — p != nullptr ? p->x : 0이 안전한 이유다.
둘째, 결과의 타입은 하나로 정해진다. 이것이 초보자가 가장 자주 헛디디는 자리다. 두 가지의 타입이 다르면 통상 산술 변환(29장)이 적용되어 공통 타입 하나가 되고, 그 타입은 조건의 값과 무관하게 컴파일 시간에 확정된다.
examples/ch33/ternary.c
#include <stdio.h>
int main(void)
{
int i = 7;
double d = 2.5;
/* 조건 연산자의 타입은 *컴파일 시간에 하나로* 정해진다.
두 가지가 int 와 double 이면 결과는 언제나 double 이다. */
printf("sizeof(int)=%zu sizeof(double)=%zu\n", sizeof i, sizeof d);
printf("sizeof(1 ? i : d) = %zu (double even when the condition is true)\n", sizeof(1 ? i : d));
printf("value (1 ? i : d) = %.1f\n", 1 ? i : d); /* 7 이 아니라 7.0 */
/* 부호가 섞이면 통상 산술 변환이 그대로 적용된다.
(아래는 컴파일러 경고를 피하려고 미리 맞춰 준 판이다 —
맞추지 않으면 gcc 가 -Wsign-compare 로 이 자리를 짚는다) */
int neg = -1;
unsigned one = 1u;
unsigned mixed = 1 ? (unsigned)neg : one;
printf("(1 ? neg : one) becomes unsigned = %u\n", mixed);
/* 문자 상수는 C 에서 이미 int 다 */
printf("sizeof('a')=%zu sizeof(1 ? 'a' : 'b')=%zu\n",
sizeof('a'), sizeof(1 ? 'a' : 'b'));
/* 포인터 쪽 규칙: 한쪽이 널 포인터 상수면 결과는 다른 쪽의 타입 */
const char *s = "text";
const char *r = 1 ? s : NULL;
printf("pointer branch: %s\n", r);
return 0;
}
실행 결과
sizeof(int)=4 sizeof(double)=8
sizeof(1 ? i : d) = 8 (double even when the condition is true)
value (1 ? i : d) = 7.0
(1 ? neg : one) becomes unsigned = 4294967295
sizeof('a')=4 sizeof(1 ? 'a' : 'b')=4
pointer branch: text
1 ? i : d에서 조건이 참이라 i(int)가 골라졌는데도 결과의 크기는 8바이트이고 값은 7.0이다. int와 double이 만나 double로 통일된 것이다. 부호가 섞이면 같은 규칙이 더 사납게 나타난다 — -1이 4294967295가 되는 마지막 줄이 그 증거이고, gcc는 이 자리를 실제로 이렇게 짚어 준다.
error: operand of ‘?:’ changes signedness from ‘int’ to ‘unsigned int’
due to unsignedness of other operand [-Werror=sign-compare]셋째, 그래서 “조건에 따라 다른 타입을 돌려주는” 용도로는 쓸 수 없다. cond ? 3 : "three" 같은 코드는 애초에 컴파일되지 않고, cond ? 1 : 2.0 처럼 컴파일되는 조합은 양쪽이 같은 타입으로 변환된 뒤다. C의 수식은 값이 하나의 정적 타입을 갖는다는 원칙(23장)이 여기서도 그대로 지켜지는 것이다. 타입이 갈리는 선택이 필요하면 방법은 둘뿐이다 — 48장의 태그된 공용체로 “여러 타입 중 하나”를 담는 타입을 만들거나, 58장의 _Generic 으로 컴파일 시간에 갈래를 고르는 것이다.
포인터 쪽에는 편의 규칙이 하나 더 있다. 한쪽이 널 포인터 상수이면 결과는 다른 쪽의 포인터 타입이 된다 — 예제의 마지막 줄이 그 경우다.
33.6 연산자 한눈에 — 지금까지 만난 것들
제6부를 닫으며 연산자를 한자리에 모은다. 이 책은 필요한 자리마다 조금씩 꺼내 왔으므로(20장의 최소 집합에서 시작했다), 여기서 전체를 한눈에 보는 것이 처음이다. 우선순위와 결합은 부록 A에 있다.
| 갈래 | 연산자 | 한 줄 소개 |
|---|---|---|
| 산술 | + - * / % | %는 정수에만. 나눗셈은 0을 향해 버린다(28장) |
| 증감 | ++ -- | 전위는 바꾸고 나서, 후위는 바꾸기 전 값(32장) |
| 비교 | < <= > >= == != | 결과는 bool. ==와 =를 섞지 않는다(30장) |
| 논리 | && || ! | 단락 평가의 보장이 있다(30장) |
| 비트 | & | ^ ~ | 무부호 위에서 쓴다(28장) |
| 시프트 | << >> | 폭 이상 옮기면 계약 밖(7장) |
| 대입 | = 와 복합 += -= *= /= %= &= |= ^= <<= >>= | 복합 대입은 왼쪽을 한 번만 평가한다 |
| 조건 | ?: | 수식 자리의 갈래. 타입은 하나로 통일된다(이 장) |
| 기억 | & * [] . -> | 주소·역참조·첨자·멤버(31·35·46장) |
| 크기·정렬 | sizeof alignof | 값이 아니라 타입을 묻는다. 대개 평가되지 않는다 |
| 형변환 | (타입) | 명시적 변환. 필요할 때만(24·29장) |
| 쉼표 | , | 왼쪽을 평가해 버리고 오른쪽이 값이 된다 |
표 33.1
표에서 두 항목만 따로 짚는다.
복합 대입은 단순한 줄임이 아니다. a[f(i)] += 1은 a[f(i)] = a[f(i)] + 1 과 달리 왼쪽을 한 번만 평가한다 — f가 두 번 불리지 않는다는 뜻이다.
쉼표 연산자는 “왼쪽을 평가하고 버린 뒤 오른쪽 값을 결과로 삼는” 연산자 다. for (i = 0, j = n; i < j; i++, j--)처럼 for의 머리에서 두 가지를 함께 움직일 때가 거의 유일한 정당한 쓰임이고, 그 밖에서는 읽기를 해친다. 함수 호출의 인자 구분자와 모양이 같아 헷갈리기 쉽다는 것도 기억해 둘 만하다 — f(a, b)의 쉼표는 연산자가 아니라 문법의 일부다.
끝으로, 다음을 위한 씨앗 하나. fact는 사실 암묵의 약속 위에 서 있다 — “n은 0 이상이어야 하고(음수면 바닥에 안 닿는다), 13 이상이면 int 그릇이 넘친다(27장).” 함수가 재료에 거는 조건(전제조건)과 결과에 관해 약속하는 것(후조건) — 이 관점으로 함수를 바라보기 시작하면, 함수 하나하나가 작은 계약서로 보인다. 이 관점이 오류 처리(51장)와 proven의 설계 철학(43장)의 뿌리이고, 제9부에서 정면으로 다룬다.
함수의 의미를 갖췄으니, 이 부를 닫는 장이 하나 남았다. 방금 세운 어휘 — 부수효과와 평가 순서 — 를 가장 자주 쓰는 연산자에 적용해 볼 차례다. 다음 장은 대입이다. 매일 쓰면서도 얕게 이해되는 연산자이고, C에서 가장 유명한 계약 위반들이 모여 있는 자리이기도 하다.