매개변수와 인자
서로 바꿔 쓰이지만 구별할 가치가 있는 두 단어입니다.
- 매개변수(parameter) 는 함수 정의 안의 변수입니다 -
int square(int n)의int n. - 인자(argument) 는 호출 지점에서 건네는 값입니다 -
square(4)의4.
함수를 호출하면 매개변수가 새로운 지역 변수로 만들어지고, 인자가 그 안으로 복사됩니다. 그 복사가 이 페이지의 주제 전부입니다.
C는 값으로 전달합니다 - 언제나
이것이 규칙이고, C에는 예외가 없습니다. 함수는 인자의 사본을 받습니다. 매개변수에 대입하는 것은 사본만 바꿉니다.
value는 여전히 42입니다. 함수는 x라는 사본을 받아 그 사본을 0으로 만들었고, 함수가 반환되자 사본은 사라졌습니다. 매개변수 이름을 value로 바꿔도 달라지는 건 없습니다. 이름이 둘을 연결하는 게 아니라, 그저 서로 다른 두 변수입니다.
모든 타입에 대해 마찬가지입니다. int, double, char, 포인터, 심지어 구조체 전체까지(필드별로 복사됩니다). 파이썬, 자바, 자바스크립트에서 왔다면 유의하세요. 그 언어들은 객체 참조 를 값으로 전달하므로 함수 안에서 객체를 변형하면 바깥에서도 보입니다. C에는 그것이 없고, 객체 자체를 복사합니다.
값에 의한 전달은 한계가 아니라 기능입니다. 함수가 실수로 호출자의 데이터를 망가뜨릴 수 없으므로 추론하기가 훨씬 쉬워집니다.
고전적인 실패 사례: swap
교과서적인 예시는 두 값을 맞바꾸려는 함수입니다.
논리는 옳습니다 - 실제로 a와 b를 맞바꿉니다. 하지만 a와 b는 사본이므로, 함수가 반환되는 순간 그 일은 버려집니다.
포인터로 참조 전달 흉내 내기
함수가 호출자의 변수를 바꾸게 하려면 값 대신 변수의 주소를 주세요. 그 주소 자체는 값으로 전달되어 포인터가 복사되지만, 사본도 여전히 원래 객체를 가리키므로 그것을 통해 쓰면 호출자의 메모리에 닿습니다.
세 가지 문법이 이 아이디어 전부를 담고 있습니다.
- 매개변수 목록의
int *a는a를 "int를 가리키는 포인터"로 선언합니다. - 호출 지점의
&x는x의 주소를 만들어 냅니다. - 함수 안의
*a는 "a가 가리키는int"를 뜻하며, 읽을 수도 대입할 수도 있습니다.
C 프로그래머가 "참조 전달"이라고 할 때 뜻하는 것이 이것인데, 정확히 말할 가치가 있습니다. 여전히 값에 의한 전달입니다. 복사되는 것은 포인터 입니다. a 자체에 대입하면(a = NULL;) 사본만 바뀌고, *a에 대입하면 호출자의 변수가 바뀝니다. 포인터가 이 모델을 온전히 다룹니다.
대가는 호출 지점만 보고는 변수가 바뀔 수 있는지 알 수 없게 된다는 점이며, 바로 그래서 &가 필수입니다. swap(x, y)는 컴파일되지 않습니다. swap(&x, &y)라고 써야 하고, 그 &가 이 호출이 x를 수정할 수 있다는 눈에 보이는 표시입니다.
결과를 여러 개 돌려주기
return은 값 하나를 만들어 내므로, 추가 출력은 포인터 매개변수를 통해 이동합니다. 흔한 모양은 반환값으로 상태 코드를 주고 진짜 결과는 포인터를 통해 쓰는 것입니다.
호출이 실패하면 q와 r은 이전 값을 유지한다는 점에 주목하세요. 함수가 아무것도 쓰기 전에 반환했기 때문입니다. 그것이 호출자가 지켜야 할 계약이고, 출력을 쓰기 전에 상태를 검사하는 이유입니다.
다른 방법은 두 필드를 담은 구조체를 반환하는 것으로, 포인터를 아예 피하게 해 주며 값들이 진정으로 함께 속할 때 흔히 더 나은 설계입니다.
배열은 다릅니다
배열은 복사 규칙을 따르지 않으며, 이 점이 거의 모두를 걸려 넘어지게 합니다. 배열을 전달하면 첫 원소를 가리키는 포인터로 붕괴(decay) 하므로, 함수는 데이터의 사본이 아니라 주소를 받습니다.
호출자의 배열이 실제로 바뀌었고, &는 어디에도 없습니다. 이 맥락에서 data가 이미 주소이기 때문입니다. 세 가지 결론이 따릅니다.
매개변수 목록에서 int a[]와 int *a는 정확히 같은 뜻입니다. int a[100]도 마찬가지이며, 크기는 무시됩니다. int a[]라고 쓰는 것은 의도를 문서화하지만, "배열이 복사된다"로 읽지는 마세요.
함수 안의 sizeof는 틀립니다. 이것이 그 버그입니다.
main에서 sizeof(data)는 20바이트이고 길이는 5로 나옵니다. 함수 안의 sizeof(a)는 포인터 의 크기 - 64비트 시스템에서 8 - 이므로 "길이"는 2가 됩니다. 배열의 크기는 그곳에서 그냥 얻을 수 없습니다. 항상 길이를 별도의 매개변수로 넘기세요. 요즘 컴파일러는 -Wall에서 이에 대해 경고합니다.
쓰지 않을 것이라면 const를 쓰세요. const int a[]는 함수가 읽기만 한다고 말하며, 컴파일러가 이를 강제하고 읽는 사람도 믿을 수 있습니다.
int sum(const int a[], int n); /* 배열을 수정하지 않겠다는 약속 */
문자열도 배열이므로 같은 이야기가 적용됩니다. void greet(const char *name)은 문자열을 바꾸지 않고 읽는 함수의 표준 시그니처입니다.
비용은 어떨까요?
큰 구조체를 값으로 전달하면 호출마다 모든 바이트가 복사됩니다. 필드가 몇 개뿐인 구조체라면 괜찮고, 흔히 포인터가 더하는 간접 참조보다 빠릅니다. 큰 구조체라면 포인터를 넘기고, 함수가 읽기만 한다면 const를 붙이세요.
struct Config { char name[64]; int flags[32]; double weights[128]; };
void applyConfig(const struct Config *cfg); /* 호출마다 512바이트를 복사하지 않음 */
const struct Config *cfg는 "수정하지 않을 Config를 가리키는 포인터"를 뜻하며, 포인터의 효율과 사본의 안전함을 함께 얻습니다.
흔한 실수
- 평범한 매개변수가 호출자의 변수를 바꾸리라 기대하기. 불가능합니다. 포인터를 넘기세요.
- 호출 지점에서
&를 잊기. 포인터를 원하는swap에swap(x, y)는 타입 오류입니다. 컴파일러가 잡아 주지만, 프로토타입이 스코프에 있을 때만 그렇습니다. - 함수 안에서
*를 잊기. 두 포인터 매개변수에 대한a = b;는 값이 아니라 지역 사본을 맞바꿉니다. 원하는 건*a = *b;입니다. - 배열 매개변수에
sizeof쓰기. 언제나 틀립니다. 길이를 넘기세요. - 지역 변수를 가리키는 포인터 반환하기. 함수가 반환되면 지역 변수는 죽으므로 호출자는 대롱거리는 포인터를 들게 되고, 이는 미정의 동작입니다. 값으로 반환하거나, 호출자가 준 버퍼에 쓰거나,
malloc으로 할당하세요. - 포인터 매개변수의
NULL검사를 하지 않기. 건네받은 것을 그대로 역참조하는 함수는 널 인자에서 크래시가 납니다.
자주 묻는 질문
C에 참조에 의한 전달이 있나요?
없습니다. C에는 값에 의한 전달이라는 단 하나의 방식만 있습니다. 함수는 언제나 각 인자의 사본을 받으므로, 매개변수에 대입해도 호출자의 변수는 건드리지 않습니다. 참조 전달처럼 보이는 것은 포인터를 값으로 전달하는 것입니다. 포인터가 복사되지만 그 포인터는 여전히 호출자의 객체를 가리키므로, 함수는 포인터를 통해 그것을 수정할 수 있습니다.
C에서 배열을 함수에 어떻게 전달하나요?
매개변수를 int a[]나 int *a로 쓰고(둘은 같은 뜻입니다) 길이를 별도의 매개변수로 넘깁니다. int sum(const int a[], int n)처럼요. 배열은 복사되지 않고, 함수는 첫 원소를 가리키는 포인터를 받으므로 a[i]의 변경이 호출자에게 보입니다.
C에서 배열 매개변수에 왜 sizeof가 통하지 않나요?
매개변수가 실제로는 포인터이기 때문입니다. 함수 안의 sizeof(a)는 배열 크기가 아니라 포인터 크기(보통 8바이트)를 주므로, sizeof(a)/sizeof(a[0])은 원소 개수 대신 2 같은 값을 냅니다. 항상 길이를 명시적으로 넘기세요.
C 함수가 값을 여러 개 반환하려면 어떻게 하나요?
추가 결과를 위한 포인터를 넘기고 그것을 통해 씁니다. int divide(int a, int b, int *quotient, int *remainder)는 성공 플래그를 반환하면서 두 출력을 채웁니다. 다른 방법은 값들을 한데 묶은 구조체를 반환하는 것입니다.