나무모에 미러 (일반/밝은 화면)
최근 수정 시각 : 2024-10-12 17:23:01

참조에 의한 호출

프로그래밍 언어 문법
{{{#!wiki style="margin: -16px -11px; word-break: keep-all"<colbgcolor=#0095c7><colcolor=#fff,#000> 언어 문법 C(포인터 · 구조체) · C++(자료형 · 클래스 · 이름공간 · 상수 표현식 · 특성) · C# · Java · Python · Kotlin · MATLAB · SQL · PHP · JavaScript · Haskell
마크업 문법 HTML · CSS
개념과 용어 함수 · 인라인 함수 · 고차 함수 · 람다식 · 리터럴 · size_t · 상속 · 예외 · 조건문 · 참조에 의한 호출 · eval
기타 == · === · deprecated · NaN · null · undefined · 모나드 · 배커스-나우르 표기법
프로그래밍 언어 예제 · 목록 · 분류 }}}

1. 개요2. 설명
2.1. 컴퓨터 메모리 단계에서의 이해
3. 예시4. 주소에 의한 호출과의 차이
4.1. 상세한 설명4.2. 코드를 이용한 비교

1. 개요

Call by Reference

프로그래밍 언어에서 함수 혹은 프로시저를 호출할 때 인자를 다루는 방법 중의 하나.

값을 복사해서 전달하는 '값에 의한 호출(Call by Value)'과 대척되는 개념이다.

call-by- 대신 pass-by- 라고 부르기도 한다.

C(프로그래밍 언어)에는 참조에 의한 호출이 존재하지 않는다. C에는 참조(reference) 자체가 없으며 C에서는 오직 값에 의한 호출만 지원한다. 다들 C에서 참조에 의한 호출이라고 알고있는 것은 실은 전달인자에 주소값을 전해주는 방법으로 참조에 의한 전달을 흉내(시뮬레이트) 하는 것이다.

참조에 의한 호출은 함수에서 함수 외부 메모리 공간을 참조할 때 사용하며, 함수 선언시 매개변수에 &를 사용해 변수의 위치를 받도록 하고 함수 내부에서는 위치를 준 변수를 일반 변수처럼 사용한다. 그래서 포인터에 의한 간접 접근인 C의 방법은 참조에 의한 호출이 아닌 것이다.

때문에 C를 다루면서 참조에 의한 호출을 언급하는 것은 엄연히 틀린 것으로, 국내는 물론이고 미국조차도 C의 주소 호출 방식을 call by reference 라고 가르치는 교재나 강사가 많은 것도 현실이다. 그렇긴 한데 수단이 조금 다를 뿐 어차피 목적하는 바도 비슷하고 하나하나 따지지 않아도 큰 문제는 없다. 오히려 맥락을 무시하고 'C에는 참조에 의한 호출이라는 개념이 없어요' 같은 말을 하며 경찰 행세를 하는 하는 것이야말로 시간 낭비일 뿐이다.

Java 에서도 프리미티브 타입의 함수 인자 전달은 오로지 값에 의한 호출이며 객체를 전달할 때만 참조에 의한 전달이 발생한다. 그래서 자바로는 C나 C++에서 간단하게 만들 수 있는 스왑 함수를 만들 수 없다. 대신 정 하고싶으면 다른 방법을 사용해야 하는데 대표적으로 배열에 넣어 타입을 바꾼다음 교환하거나 연산의 우선순위를 이용해서 b = func(a, a=b) 라는 함수를 만들어 돌리는 방법 등이 있다.

2. 설명

함수를 호출할 때 원칙적으로는 피호출부에서는 반환값을 제외하고 호출부의 어떠한 변수도 변경할 수 없다. 그리고 모든 함수는 매개변수를 '복사' 방식으로 전달받는다. 하지만 함수는 반환값을 하나만 가질 수 있고 데이터를 담은 버퍼 오브젝트 등은 복사에 들어가는 부담이 크기에 모든 것을 원칙적으로 처리할 수는 없다.

그런데 함수는 포인터참조자를 통해 메모리를 직접 액세스하는 방식으로 자신에게 주어진 격리 공간을 탈출해서 외부 세계에 간섭하는 게 가능하다. 똑같은 값에 의한 호출이지만 전달하는 그 자체가 메모리 주소 즉 포인터 값이다. 그리고 함수 내부에서는 포인터 역참조 연산자를 통해 해당 메모리 주소에 직접 접근해 값을 수정함으로써 호출부의 메모리 공간에 직접 액세스한다. 이걸 편의상 참조에 의한 호출로 설명하는 것이다.

객체는 대부분의 프로그래밍 언어에서 참조에 의한 호출로 생성된다. 클래스는 해당 객체의 뼈대만 정의할 뿐, 객체 그 자체가 아니기 때문.

2.1. 컴퓨터 메모리 단계에서의 이해

프로그래밍 언어론에서 정의하는 함수는 본래의 프로그램과 완벽하게 분리되어서 어떠한 부수 효과도 일으키지 않는 구조적인 코드 블럭을 의미하지만, 현대에 이르러서는 프로시저와 혼용된다.

함수는 스택 프레임(Stack Frame)이라는 메모리 구조를 통해서 만들어질 수 있다. 대부분의 언어에서 지원을 해 주는 함수는 이러한 메모리 구조를 바탕으로 이루어져 있고, 매개변수에 해당하는 부분 또한 스택 프레임에 저장된다. this를 지원하는 프로그래밍 언어의 경우 이것도 저장된다.

스택 프레임의 매개변수에 저장되는 해당 값이 리터럴 형태의 상수인가, 아니면 어떠한 변수를 가리키는 포인터 값이느냐에 따라서 매개변수를 값에 의한 호출과 참조에 의한 호출로 구분할 수 있다. 이 때, 참조에 의한 호출은 스택 프레임의 매개변수에 지정되는 값이 어떠한 변수의 주소값을 가리킨다. 그렇기 때문에 엄격한 함수의 정의에서 벗어나서 프로시저처럼 부수 효과를 일으킬 수 있다.

뿐만이 아니라, 단순히 컴퓨터 메모리에서의 스택 프레임의 문제가 아니라. 컴파일러 내에서 함수에 대한 정책을 어떻게 책정하느냐에 따라 컴파일 과정에서 해당 변수 정책이 바뀔수도 있다. 일반적으로 값에 의한 호출이나 참조에 의한 호출은 함수냐 프로시저냐의 생각으로 이해할 수 있고. 호출에 대한 정책은 해당 프로그래밍 언어의 특징에 따른다.

따라서, 대부분의 프로그래밍 언어에서의 함수라는 것에 대한 기본 값 전달 방식은 값에 의한 호출의 형태다.

아래는 값에 의한 호출과 참조에 의한 호출 간의 차이점이다. 참조하자.

3. 예시

다음과 같은 C언어 코드가 있다고 하자. (기본적으로 맨 첫 줄에 #include <stdio.h>가 쓰여있다고 가정)

#!syntax cpp
void functionA(int v) {
    v++;
}

int main(void) {
    int a = 10;

    functionA(a);

    printf("%d", a);
}

결과값: 10
이와 같이 짜여진 함수는 main 함수 내의 변수 a의 값을 functionA에서 바꿀 수 없다. functionA의 스택 프레임에는 변수 a가 없기 때문이다. 하지만 C++에서 다음과 같이 작성한다면(기본적으로 맨 첫줄에 #include <iostream>이 쓰여있다고 가정)

#!syntax cpp
void functionB(int& v) {
    v++;
}

int main(void) {
    int a = 10;

    functionB(a);

    printf("%d", a);
}

결과값: 11
이렇게 하면 각 함수의 스택 프레임은 각기 따로 생기지만, main 함수의 변수 a를 참조하여 functionB에서 변경할 수 있다. 스택 프레임을 깨뜨리고 그냥 프로세스에 할당된 메모리 맵 전체를 대상으로 절대 주소 참조를 하기 때문이다. 좀 더 쉽게 말하면 functionB는 main 함수가 메모리를 어떻게 사용하고 있는지 전혀 모르지만 main 함수가 사용하고 있는 메모리 공간에 직접 좌표를 찍어서 강제로 값을 변경하는 것이다.

좌표를 찍는 것이기 때문에 좌표를 강제로 옮겨 버리면 엉뚱한 값을 바꾸게 된다. 예를 들어

#!syntax cpp
void functionC(int& v) {
    (*(&v+1))++;
}

int main(void) {
    int a = 10;
    int b = 1;

    functionC(a);

    printf("%d, %d", a, b);
}

결과값: 10, 2
매개변수에 a를 전달했는데도 불구하고 엉뚱하게 b의 값이 변경되었다! main 함수의 a가 가진 주소 값에 1을 더한 위치에 b가 있었던 것이다. 거기에 증가 연산(++)을 하니 결국 b의 값이 늘어난 것. 참조에 의한 호출이 좌표를 찍는 방식이라는 건 바로 이것을 의미한다. 여기서 변수 b를 선언하는 부분을 빼고(printf 부분도 a만 출력하게 다듬고) 컴파일하면 컴파일러는 아무 경고 없이 컴파일을 하지만 실행하면 stack smashing detected라는 에러 메시지를 띄우며(리눅스 커널 4.4.0, g++ 컴파일러 5.4.0 버전 기준) 프로그램이 강제 종료된다. 버퍼 오버플로우와 비슷한 상황인데 데이터 영역을 벗어나 코드 영역에 쓰기를 시도해서 OS가 프로그램 실행을 강제로 중지한 것이다. 컴파일러는 코드에 무슨 이상이 있는지 감지하지 못해서 컴파일을 통과시켰지만 메모리 관리를 총괄하는 OS가 메모리의 부정 접근을 탐지, 또는 CPU가 쓰기 금지된 메모리 페이지에 쓰기 시도(코드 영역은 컴파일러가 쓰기 금지 상태로 메모리에 적재되도록 만든다)를 감지해서 프로그램을 강제종료시킨 것.

참고로 OS는 프로세스마다 완전히 격리된 가상 메모리 공간을 할당하므로, OS한테 특별히 요청해서 세마포어를 할당받지 않는 한 아무리 좌표를 찍으려 해도 다른 프로세스의 메모리까지는 건드리지 못한다.[1]

4. 주소에 의한 호출과의 차이

4.1. 상세한 설명

많은 초보자들이 주소에 의한 호출과 참조에 의한 호출을 헷갈려하는데 이건 C언어의 포인터 문법의 난해함이 크게 기여했다. 사실 C의 모든 함수는 값에 의한 호출 한다. 즉 값에 의한 호출만 사용한다는 것. 값으로 주소값을 전달하느냐 값 자체를 전달하느냐의 차이인데 포인터를 이용해서 결과적으로 참조에 의한 호출과 동일한 효과를 낼 뿐이다. 여기에 포인터 연산자인 *가 선언할 때와 사용할 때의 용법이 반대라는 점, 참조 연산자 &가 더해졌다는 점이 이러한 혼란을 가중시켰다.

값에 의한 호출, 참조에 의한 호출, 주소에 의한 호출 모두 매개변수에 을 전달하는 건 똑같다. 값에 의한 호출은 명백하니 넘어가고, 주소에 의한 호출은 함수의 호출 측에서는 값을 전달하는 것처럼 보이나 받는 쪽에서는 포인터로 받는다는 것, 그리고 참조에 의한 호출은 함수의 호출 측에서 처음부터 포인터를 명시적으로 전달한다는 것의 차이가 있다. 사실 컴파일러가 컴파일한 뒤의 결과는 똑같다.[2] 단지 함수 선언부에서 문법을 포인터로 선언했냐, 참조자로 선언했냐의 차이 뿐이다.

상기 단락에서 서술한 예시를 주소에 의한 호출로 고쳐보자.
#!syntax cpp
void functionD(int* v) {
    (*v)++;
}

int main(void) {
    int a = 10;

    functionD(&a);

    printf("%d", a);
}

결과값: 11
포인터를 공부했다면 익숙하게 접할 수 있는 코드인데, 이를 두고 참조에 의한 호출이라고 말하는 실수를 많이 저지른다. 왜 이것이 잘못된 표현인지는 연산자가 하는 일을 주의 깊게 따라가면 알 수 있다. 컴파일러는 변수를 다룰 때 변수의 타입과 메모리 맵 상의 배치(포인터 주소)를 기억한다. 값 자체는 컴파일러가 처리하는 게 아니라 OS가 프로그램을 메모리에 적재할 때 컴파일러가 만들어 둔 메모리 맵을 보고 포인터가 가리키는 실제 주소에 값을 적재한다.

따라서 v라는 변수를 선언했다면 세 가지 정보를 뽑아낼 수 있다. 하나는 v의 값(v), 다른 하나는 v의 주소(&v), 그리고 마지막으로 v의 타입(typeof(v)). 마지막 typeof는 함수처럼 생겼지만 연산자이고 키워드의 일종이다. 이걸 보면 C언어의 문법적 해괴함이 좀 와닿을 것이다. 사실 연산자도 수식 표기법을 따질 때에는 함수의 일종이라고 볼 수 있고 그 반대도 마찬가지다.

여기까지 잘 따라왔다면 포인터에 대해 어느 정도 이해가 됐을 것이다. 컴퓨터공학 전공자라고 해도 어셈블리어 과목을 A학점 이상으로 이수하기 전에는 힘들다. main 함수에서는 일반 값 변수인 a를 선언했다. 따라서 컴파일러는 main 함수의 스택 프레임에 변수 a의 메모리 맵을 작성해 두었다. 즉 실제 값이 저장된 공간이 main 함수의 스택 프레임에 생겨났다. 다음으로, functionD는 int의 포인터를 매개변수로 선언하였다. 윗 문단의 functionB에서는 &b 형태의 매개변수를 선언했는데 이 형태는 컴파일러가 내부적으로 포인터 매개변수로 바꿔 처리하게 되어 있다. 그냥 프로그래머한테 주소 변환 및 역변환 절차를 안 보이게 가려놨을 뿐이다.

함수 호출시에 &v로 선언한 functionB 코드에서는 그냥 a를 전달했지만 *v로 선언한 이번 코드에서는 &a를 전달한다. &a는 변수 a의 주소를 반환하는 연산자라고 설명한 바 있다. functionD가 선언할 때 int 타입의 포인터를 받게 선언했으므로 포인터 값을 프로그래머가 명시적으로 전달한 것이다. 참조자로 선언한 코드는 컴파일러가 이 포인터 변환 연산을 처리하지만 포인터로 선언한 코드는 프로그래머가 명시적으로 해 줘야 한다.

보이는 결과값만으로는 분명 참조에 의한 호출과 동일하고, 동작도 비슷해보이겠지만 실제로는 다르다. 예를 들어, main 함수에 정의된 int a의 주소값이 0x1000이라고 치자면, 참조에 의한 호출 예시에서는 functionB의 int& v의 주소값도 0x1000이다. 하지만 주소에 의한 호출 예시에서 functionD의 int* v는 functionD 스택에서 정의된 새로운 포인터 변수이며, int* v의 주소값은 0x1000이 아닌, functionD에서 생성된 새로운 주소값(이를테면 0x1012 등)이고, 이 변수에 저장된 값이 0x1000인 것이다. 즉, functionD 함수에서 매개변수로 정의된 int* v에 main 함수의 a의 주소값을 복사함으로서 매개변수 v가 main 함수의 a에 접근할 수 있는 것.

반면 참조자는 참조 대상과 주소 값이 같다는 특성 때문에 참조 대상의 '별칭(alias)'이라고도 불린다. C++의 창시자 비아르네 스트로우스트루프가 쓴 <The C++ Programming Language>에도 "A reference is an alternative name for an object."라고 적혀 있다. 어떤 함수의 매개변수가 int& ref로 선언되고 main 함수에서는 이 함수에 int형 변수 num을 인자로 전달한다고 할 때, 함수 내에서 참조자 ref에 ++ 연산을 하면 main 함수의 num이 ref와 같은 메모리 공간을 나타내므로 num의 값이 1 증가하게 된다.

그럼 참조자로 선언하면 0x1012에 해당하는 새로운 변수가 할당되지 않는 걸까? 할당된다. 컴파일러가 알아서 처리하는 익명의 포인터 변수여서 프로그래머가 접근할 수 없을 뿐이다. 새로 만들어지는 익명 변수의 이름을 PTR이라고 가정했을 때, 프로그램 내부에서 int& ref = num;을 만나면 컴파일러에 의해 ref와 1:1로 대응되는 포인터 변수 PTR이 생성되고 연산식은 const int* PTR = &num;으로 바뀌어 계산된다.[6] 따라서 ref++;는 결과적으로 (*PTR)++;가 된다고 할 수 있다. 만약 함수 내에서 ref의 주소값(=num의 주소값)을 출력하기 위해 cout << &ref;를 입력하면, 실제로 컴파일러가 계산하는 것은 cout << PTR;인 것이다.

정리를 하자면 C의 주소에 의한 호출은 명시적인 포인터를 통한 호출을 의미하며, 본질적으로는 값에 의한 호출이지만 그 '값'이 주소값이기에 구분을 위하여 '주소에 의한 호출'이라고 부르는 것이다. 주소에 의한 호출은 아랫단락에 서술될 인자값 비교를 제외하면 참조에 의한 호출과 동일한 결과를 낼 수 있다. 하지만 이건 어디까지나 흉내를 낸 것이고, 함수 내에서 인자값을 변화시키는 것이 불가능하기에 C에는 참조에 의한 호출이 존재하지 않는다고 해야 한다. 실제로 참조에 의한 호출를 행하는 것은 C++의 참조자이며 참조자 역시 내부적으로는 (const) 포인터로 변환되어 동작하니, 결국 포인터와 참조자 모두 주소값을 복사하여 대상에 접근한다는 공통점이 있다.

4.2. 코드를 이용한 비교

C언어의 창시자 데니스 리치가 쓴 The C Programming Language를 보면, 값에 의한 호출 단락에서 다음과 같이 참조에 의한 호출을 설명하고 있다.
..."call by reference"..., in which the called routine has access to the original argument, not a local copy.
함수 호출 과정에서 어떤 변수를 전달한다고 할 때, 이 변수를 호출부에서는 인자(Argument), 또는 실 매개변수(Actual Parameter)라고 하며 피호출부에서는 매개변수(Parameter), 또는 형식 매개변수(Formal Parameter)라고 한다. 일반적으로 '매개변수'라 하면 '형식 매개변수'를 뜻한다. 위에 서술된 설명은 호출된 함수가 인자에 접근할 수 있는 것이 참조에 의한 호출이라는 말이다. 아래의 C++ 코드를 보자.

#!syntax cpp
void testFunc(int& pRef) {
    pRef = 20;
}

int main(void) {
    int num = 0;
    int& ref = num;

    cout << ref << endl;
    testFunc(ref);
    cout << ref << endl;
}

0
20
함수 testFunc 안에서 참조자 매개변수 pRef에 20을 대입했더니, main 함수에서 전달된 인자 ref의 값이 0에서 20으로 변경되었다. 즉 부수 작용이 일어났다. 이런 게 가능해야 참조에 의한 호출이 지원되는 언어라고 할 수 있다. 이걸 포인터로 변환하면 어떻게 될까?

#!syntax cpp
void testFunc(int* pPtr) {
    pPtr = NULL;
}

int main(void) {
    int num = 0;
    int* ptr = &num;

    cout << ptr << endl;
    testFunc(ptr);
    cout << ptr << endl;
}

0x65fe44
0x65fe44
함수 testFunc 안에서 포인터 매개변수 pPtr에 NULL을 대입했지만, main 함수에서 전달된 인자 ptr의 값은 변하지 않았다. 이 코드에서 testFunc 함수를 통해 ptr에 저장된 값을 변경할 수 있는 방법은 없다. ptr에 저장된 값은 main 함수에 선언된 num의 주소값인데, ptr의 값을 바꾼다는 것은 pPtr을 이용해서 ptr이 num이 아닌 다른 변수를 가리키도록 만든다는 뜻이기 때문이다. 그러나 그런 것은 불가능하다.

참조자의 예시에서 pRef에 20을 대입하면 main 함수의 num의 값도 20이 되는데, 마찬가지로 포인터의 예시에서 testFunc 함수 안에 '*pPtr = 20;'이라는 식을 추가하면 main 함수의 num의 값이 20으로 바뀐다. 하지만 이는 역참조 연산자(*, Dereference Operator)를 이용한 절대 주소로 num에 접근한 것이지 인자 ptr 자체의 값이 바뀐 것은 아니다. 포인터를 이용해서 참조에 의한 호출 흉내냈을(Simulate) 뿐이다. 따라서, 참조자가 없고 포인터만 존재하는 C언어에는 참조에 의한 호출이 존재하지 않는다는 결론을 내릴 수 있다.

C에서 참조에 의한 호출을 가장 가깝게 흉내 내려면 이중 포인터를 사용하여 포인터 변수의 내용이 아닌 주소 자체를 넘기면 된다.
#!syntax cpp
#include <stdio.h> // printf()

void testFunc(int** pPtr) {
    *pPtr = NULL; // Dereferencing. main()의 int* ptr := NULL
}

int main(void) {
    int num = 0;
    int* ptr = &num;

    printf("%p \n", ptr);
    testFunc(&ptr); // int* ptr; 의 내용이 아닌 주소 자체를 전달한다.
    printf("%p", ptr);
}

000000bd7d7ffb5c
0000000000000000


[1] 하지만 같은 프로세스의 다른 스레드는 건드릴 수 있다. 일반적으로 부모자식 간이 아닌 스레드 사이에서 메모리 직접 참조가 발생하면 버그다. 하지만 당장 에러가 나진 않고 나중에 엉뚱한 지점에서 폭발한다. 프로그래머의 주적.[2] 컴파일러는 항상 주소값으로 연산을 하기 때문이다. 위에서 예로 든 좌표 찍기 코드(functionC)의 경우도 int& a를 int* a로 바꾸고, (&a + 1)을 (a + 1)로 바꾸면 결과는 동일하게 나온다. 참조에 의한 호출은 아니지만 명시적인 주소 값으로 호출을 하기 때문에 최종적으로 같은 결과가 출력되는 것이다.[3] 리눅스 커널, 64bit OS 기준.[4] 그런 고로 이 포인터 변수 자체의 주소를 다른 포인터 변수의 주소와 스왑하고자 할때 이중 포인터를 써야 한다.[5] 그래서 sizeof(int)는 4인데(32비트) sizeof(int *)는 8이다(64비트).[6] PTR은 const 포인터이므로, PTR을 통해 num의 값을 변경하는 것이 불가능하다. 프로그래머는 PTR에 접근할 수 없는데 PTR이 내부에서 알 수 없는 과정으로 num의 값을 변경시키면 문제가 생기니 const 선언을 하여 이를 막아놓은 것.