Proven C Book←↑→

34 함수의 의미 — 값 복사와 부수효과

먼저 알아야 할 것

25장 함수 선언과 정의 · 함수의 선언과 정의
21장 수식과 상수 · 값이 건네지는 방식

돌아보기

25장에서 매개변수는 “재료를 받는 변수 선언”이라 했다. 그러면 함수 안에서 그 매개변수를 바꾸면 — 밖에서 재료로 넘긴 변수도 바뀌는가?

답. 바뀌지 않는다 — 그리고 이것이 C 함수의 의미를 정하는 단 하나의 규칙이다: 값은 복사되어 건너간다. 매개변수는 재료의 값을 복사받은 별개의 변수이고, 함수 안의 어떤 일도 원본에 닿지 않는다. 말보다 증명이 빠르다 — 바로 시연한다.

이 장의 필요성과 맥락

25장에서 함수를 만들었지만, 호출할 때 값이 어떻게 건너가는지는 미뤄 두었다. 그 답을 여기서 갚는 것은 7부(기억) 바로 앞이기 때문이다 — 「값이 복사된다」를 알아야 「그래서 원본을 바꾸려면 주소가 필요하다」로 이어지고, 그것이 36장 포인터의 동기가 된다. 6부의 마지막 장이 다음 부의 문을 여는 구조다.

이 장이 끝나면

제6부의 마무리다. 함수 호출에서 값이 어떻게 건너가는지(복사), 부수효과와 평가 순서의 정식 규칙(21장 씨앗의 회수), 재귀(recursion)라는 새로운 반복, 그리고 조건 연산자 ?:까지. 마지막에 심는 씨앗 — 계약이라는 관점 — 은 제9부에서 꽃핀다.

이 장에서 답할 질문

  1. 값 복사가 규칙이라면, 큰 구조체를 넘길 때마다 통째로 복사되는가 — 비용이 만만치 않을 텐데.

34.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)의 첫 값이 되고, 이후 둘은 남남이다. 25장의 스코프 칸막이에 이 복사 규칙까지 더하면, 함수는 완전한 격리실이 된다: 재료는 값으로 들어오고, 결과는 return의 값으로 나가며, 그 외의 통로는 없다.

흔한 오해. “함수에 변수를 넘긴다”

일상어로는 자연스럽지만, C의 의미론으로는 틀린 문장이고 — 이 문장 대로 상상하는 순간 위 시연이 수수께끼가 된다. 넘어가는 것은 변수가 아니라 변수의 값(복사본)이다. “그러면 함수가 밖의 변수를 바꿔 주는 일은 아예 불가능한가?”라는 자연스러운 반문이 생기는데 — 가능하게 만드는 정공법이 따로 있다: 변수 대신 변수의 주소를 값으로 복사해 넘기는 것이다. 주소를 받은 함수는 그 주소로 찾아가 원본을 만진다. 26장에서 sscanf(line, "%d", &n)의 &가 정확히 이 일을 하고 있었다 — 값 복사 규칙은 그대로인 채(주소도 값이다 — 3장!), 효과는 “원본 수정”이 되는 것. 이 정공법의 문법이 36장의 포인터다.

34.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 표준에는 참조라는 개념 자체가 없다.

용어를 정확히 하면 실제로 헷갈릴 일이 줄어든다. 자주 나오는 세 물음이 같은 규칙 하나로 풀린다.

34.3 부수효과와 평가 순서 — 씨앗의 회수#

21장에서 “한 문장 안의 계산 시간 순서는 달라질 수 있다”는 씨앗만 심어 두었다. 이제 재료가 다 모였으니 정식으로 정리한다.

부수효과(side effect)란 수식 평가가 값을 내놓는 것 외에 세상의 상태를 바꾸는 모든 일이다 — 우리가 아는 것만 벌써 셋이다: 출력(23장), 대입과 +=·++(21·33장), 그리고 입력(26장). 함수 호출은 몸통이 이런 일을 하면 부수효과를 품는다.

평가 순서의 규칙은 두 문장으로 요약된다. 첫째, 한 문장(정확히는 완전 수식) 안에서 부분 수식들의 평가 순서는 대부분 미지정이다 — 함수 호출 둘이 한 수식에 있으면 어느 쪽이 먼저 불릴지 계약에 없다(21장의 “호랑이/사자”). 둘째, 문장의 끝(세미콜론)에 도달하는 순간, 그 문장이 일으킨 부수효과는 전부 완료됨이 보장된다 — 이 보장 지점을 전통 용어로 시퀀스 포인트(sequence point)라 한다. 세미콜론 외에도 몇 곳이 더 있는데, 대표가 31장에서 만난 &&·||(왼쪽 평가 완료 후 오른쪽)와 함수 호출의 경계(재료 평가 완료 후 몸통 진입)다. 현대 표준(C11 이후)은 같은 개념을 “앞서 차례 매겨짐”(sequenced before)이라는 관계로 더 정밀하게 다듬었지만, 고전 용어가 여전히 통용된다.

이 규칙에서 실전 수칙과 함정이 나온다. 수칙은 21장 그대로 — 순서가 중요한 부수효과들은 문장을 나눠라. 함정은 새로 생겼다 — ++를 배웠기 때문이다. 한 수식 안에서 같은 변수를 두 번 바꾸거나, 바꾸면서 따로 읽는 코드:

i = i++ + 1;      /* 같은 i를 두 곳에서 바꾼다 — 계약 밖 */
printf("%d %d\n", i, i++);   /* 바꾸면서 따로 읽는다 — 계약 밖 */

이런 수식은 미지정(어느 순서든 하나가 일어남)을 넘어 아예 정의되지 않은 동작이다 — 표준은 결과를 전혀 보장하지 않는다(54장). 규칙을 외울 것 없이 패턴만 기억하면 된다: 한 문장에서 한 변수는 한 번만 바꾼다. 18장의 UBSan과 컴파일러 경고가 이 패턴을 잘 잡아 주는 것도 든든한 뒷배다.

문. 값 복사가 규칙이라면, 큰 구조체를 넘길 때마다 통째로 복사되는가 — 비용이 만만치 않을 텐데.

답. 그렇다, 의미론상으로는 복사다 — 그리고 그 비용이 실무에서 포인터로 넘기는 관행의 이유가 된다(47장에서 구조체와 함께 다룬다). 다만 두 가지 단서가 있다. 첫째, 작은 값은 복사가 사실상 공짜다 — 12장에서 배운 대로 인자는 대개 레지스터로 건너가므로, 정수 몇 개를 넘기는 데는 메모리 왕복조차 없다. 둘째, 14장의 편집자가 개입한다 — 관찰 가능한 결과가 같다면 실제 복사를 생략하기도 한다. 의미는 복사, 구현은 컴파일러의 재량 — 이 구별이 성능을 걱정하기 전에 먼저 잡아야 할 관점이다.

34.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)을 품은 다섯 개의 격리실이다. 값 복사와 스코프가 만드는 격리가 없다면 재귀는 성립 하지 않는다.

재귀는 “더 작은 같은 문제”로 쪼개지는 일에 자연스러운 표현이고(나무 구조 탐색이 대표 — 47장 이후), 모든 재귀는 루프로도 적을 수 있다 (33장의 팩토리얼 루프판을 떠올리면 된다). 어느 쪽이 좋은가는 문제의 모양이 정한다 — 그리고 재귀에는 “겹겹의 호출”이 쌓이는 물리적 공간 문제가 따라오는데, 그 공간(스택)의 정체는 45장에서 만난다.

34.5 마지막 연산자, 그리고 계약이라는 씨앗#

이 부의 배분표 마지막 항목 — 조건 연산자 ?:다. 조건 ? 값1 : 값2는 “조건이 참이면 값1, 거짓이면 값2”가 되는 수식 이다. int big = a > b ? a : b;처럼 “골라 담기”를 한 줄로 적는다 — if는 문장이라 값이 될 수 없는 자리(초기화 등)에서 요긴하다. 남용하면 읽기 어려워지므로, 이 책은 “한 겹까지만”을 관행으로 삼는다.

세 가지 성질을 알아 두면 충분하다.

첫째, 고르지 않은 쪽은 평가되지 않는다. 31장의 단락 평가와 같은 보장이다 — p != nullptr ? p->x : 0이 안전한 이유다.

둘째, 결과의 타입은 하나로 정해진다. 이것이 초보자가 가장 자주 헛디디는 자리다. 두 가지의 타입이 다르면 통상 산술 변환(30장)이 적용되어 공통 타입 하나가 되고, 그 타입은 조건의 값과 무관하게 컴파일 시간에 확정된다.

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의 수식은 값마다 정적 타입이 하나라는 원칙(24장)이 여기서도 그대로 지켜지는 것이다. 타입이 갈리는 선택이 필요하면 방법은 둘뿐이다 — 49장의 태그된 공용체로 “여러 타입 중 하나”를 담는 타입을 만들거나, 63장의 _Generic 으로 컴파일 시간에 갈래를 고르는 것이다.

포인터 쪽에는 편의 규칙이 하나 더 있다. 한쪽이 널 포인터 상수이면 결과는 다른 쪽의 포인터 타입이 된다 — 예제의 마지막 줄이 그 경우다.

34.6 연산자 한눈에 — 지금까지 만난 것들#

제6부가 끝나 가는 자리에서 연산자를 한자리에 모은다. 이 책은 필요한 자리마다 조금씩 꺼내 왔으므로(21장의 최소 집합에서 시작했다), 여기서 전체를 한눈에 보는 것이 처음이다. 우선순위와 결합은 부록 A에 있다.

갈래연산자한 줄 소개
산술+ - * / %%는 정수에만. 나눗셈은 0을 향해 버린다(29장)
증감++ --전위는 바꾸고 나서, 후위는 바꾸기 전 값(33장)
비교< <= > >= == !=결과는 bool. ==와 =를 섞지 않는다(31장)
논리&& || !단락 평가의 보장이 있다(31장)
비트& | ^ ~무부호 위에서 쓴다(29장)
시프트<< >>폭 이상 옮기면 계약 밖(6장)
대입= 와 복합 += -= *= /= %= &= |= ^= <<= >>=복합 대입은 왼쪽을 한 번만 평가한다
조건?: 수식 자리의 갈래. 타입은 하나로 통일된다(이 장)
기억& * [] . ->주소·역참조·첨자·멤버(32·36·47장)
크기·정렬sizeof alignof값이 아니라 타입을 묻는다. 대개 평가되지 않는다
형변환(타입)명시적 변환. 필요할 때만(25·30장)
쉼표,왼쪽을 평가해 버리고 오른쪽이 값이 된다

표 34.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 그릇이 넘친다(28장).” 함수가 재료에 거는 조건(전제조건)과 결과에 관해 약속하는 것(후조건) — 이 관점으로 함수를 바라보기 시작하면, 함수 하나하나가 작은 계약서로 보인다. 이 관점이 오류 처리(53장)와 proven의 설계 철학(44장)의 뿌리이고, 제9부에서 정면으로 다룬다.

함수의 의미를 갖췄으니, 이 부를 닫는 장이 하나 남았다. 방금 세운 어휘 — 부수효과와 평가 순서 — 를 가장 자주 쓰는 연산자에 적용해 볼 차례다. 다음 장은 대입이다. 매일 쓰면서도 얕게 이해되는 연산자이고, C에서 가장 유명한 계약 위반들이 모여 있는 자리이기도 하다.