참조에 의한 호출

1 개요

call by reference
프로그래밍 언어에서 함수 혹은 프로시저를 호출할 때 인자를 다루는 방법의 하나.
주소에 의한 호출(Call by address)과는 비슷하면서도 다르다.
함수에서 함수 외부 메모리공간을 참조 할때 사용한다.
함수 선언시, 인자에 &를 사용해 변수의 위치를 받도록하고,
함수 내부에서, 위치를 준 변수가 사용될때처럼, 똑같이 일반변수처럼 사용한다는 것이다.
다른 개념으로 값을 복사해서 전달하는 값에 의한 호출이 있다.

2 설명

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

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

간단하게 줄여쓰면 참조는 복사해서 쓰는거고 주소는 직접 찾아가서 쓰는거다.

3 예시

다음과 같은 C++ 코드가 있다고 하자.


void functionA(int a)
{
a++;
}
int main(void)
{
int a = 10;
functionA(a);
printf("%d\n", a);
return 0;
}

결과값 : 10

이와 같이 짜여진 함수는 main 함수 내의 변수 a의 값을 functionA에서 바꿀 수 없다. main함수의 스택 프레임에 있는 변수와 functionA의 스택 프레임에 있는 변수는 분명히 다르기 때문이다. 하지만 다음과 같이 작성한다면


void functionA(int &a)
{
a++;
}
int main(void)
{
int a = 10;
functionA(a);
printf("%d\n", a);
return 0;
}

결과값 : 11

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

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

#include <stdio.h>

void functionA(int &a) {
(*(&a+1))++;
}

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

functionA(a);

printf("%d, %d\n", a, b);
return 0;
}

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

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

4 Call By Address와의 차이

많은 초보자들이 Call by Address와 Call by Reference를 헷갈려하는데 이건 C언어의 포인터 문법의 난해함이 크게 기여했다. 사실 CC++언어의 모든 함수/메서드는 모두 값에 의한 호출만 한다. 값으로 주소값을 넘기느냐 값 자체를 넘기느냐의 차이인데 여기에 포인터 연산자인 *이 선언할 때와 사용할 때의 용법이 반대이고 참조 연산자 &이 더해져 엄청난 혼란을 주었기 때문이다.

값, 주소, 참조 모두 파라메터로 을 전달하는 건 똑같다. Call by Value는 명백하니 넘어가고, Call by Reference는 함수의 호출 측에서는 값을 전달하는 것처럼 보이나 받는 쪽에서 포인터로 받는 것, 그리고 Call by Address는 함수의 호출측에서 처음부터 포인터 주소를 명시적으로 전달하는 것의 차이가 있다. 사실 컴파일러가 컴파일한 뒤의 결과는 똑같다. 단지 문법이 함수 선언부에서 레퍼런스로 선언했으냐 아니면 포인터로 선언했느냐의 차이 뿐이다.

상기 단락에서 서술한 예시를 Call By Address로 고쳐보자.

void functionA(int* a)
{
(*a)++;
}
int main(void)
{
int a = 10;
functionA(&a);
printf("%d\n", a);
return 0;
}

연산자가 하는 일을 주의 깊게 따라가야 한다. 컴파일러는 변수를 다룰 때 변수의 타입과 메모리 맵 상 배치(포인터 주소)를 기억한다. 값 자체는 컴파일러가 처리하는 게 아니라 OS가 프로그램을 메모리에 적재할 때 컴파일러가 만들어 둔 메모리 맵을 보고 포인터가 가리키는 실제 주소에 값을 적재한다.

따라서 a라는 변수를 선언했다면 a라는 변수에서는 세 가지 정보를 뽑아낼 수 있다. 하나는 a의 값(a), 다른 하나는 a의 주소(&a), 그리고 마지막으로 a의 타입(typeof(a)). 마지막 typeof는 함수처럼 생겼지만 연산자이고 키워드의 일종이다. 여기까지 봤으면 C언어의 문법적 해괴함이 좀 와닿을 것이다.
주소 참조 연산자인 & 연산자는 a의 값이 아니라 a의 주소를 반환하는 연산자이다. 그런데 선언할 때는 주소를 반환하라는 의미가 아니라 해당 타입의 레퍼런스를 사용한다는 의미로 바뀌어 버린다.
int *a 의 의미는 a라는 이름의 포인터 변수를 선언하라는 의미이다. 이 포인터 변수의 값(a)은 64비트 정수값(메모리 주소), 이 포인터 변수의 주소(&a)는 자체적으로 따로 있고, typeof(a)는 int *타입(int의 포인터 타입)으로 설정된다.[2]
하지만 선언문이 아닌 곳에서 *a 의 의미는 a 라는 포인터 변수가 가리키는 주소를 찾아가서 해당 값을 읽어라이다. 즉 선언할 때와 사용할 때의 문법이 반대이다.

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

함수 호출시에 &a로 선언한 코드에서는 그냥 a를 넘겨줬지만 *a로 선언한 이번 코드에서는 &a를 전달한다. &a는 변수 a의 주소를 반환하는 연산자라고 아까 설명했다. functionA가 선언할 때 int 타입의 포인터를 받게 선언했으므로 포인터 값을 프로그래머가 명시적으로 계산해서 넘겨 준 것이다. 레퍼런스로 선언한 코드는 컴파일러가 이 포인터 변환 연산을 처리하지만 포인터로 선언한 코드는 프로그래머가 명시적으로 해 줘야 한다.

보이는 결과 값 만으로는 분명 Call By Reference와 동일하고, 동작도 비슷해보이겠지만 실제로는 다르다. 예를 들어, main 함수에 정의된 int a의 주소값이 0x1000이라고 치자면, Call By Reference 예시에서는 functionA의 int& a의 주소값도 0x1000이다. 하지만 본 단락에서 서술한 functionA의 int* a는 functionA 스택에서 정의된 새로운 포인터 변수이며, a의 주소값은 0x1000이 아닌, functionA에서 생성된 새로운 포인터 변수의 주소값(이를테면 0x1012 등)이고, 이 변수에 저장된 값이 0x1000인 것이다. 즉, functionA 함수에서 인자로서 정의된 int* a에 main 함수의 a의 주소값을 넘김으로서 원본에 접근하는 것. 실제로 C나 C++코드를 작성하다가 포인터에 인스턴스를 할당해주는 API등을 사용할 때는 CSomeClass*&와 같은, 초심자 입장에서는 해괴해보이는 인자 자료형을 볼 수 있으니 참고해두도록 하자.

청말 많이 헷갈릴텐데 모든 C/C++에서 함수는 언제나 값에 의한 호출을 하고 그 값은 언제나 복사한다는 사실을 끊임없이 염두에 둬야 한다. Call by Address에서 *a가 0x1012 주소 공간을 따로 먹는다고 한 건, main의 a와 functionA의 a는 서로 다른 a이기 때문이다. 그럼 레퍼런스로 선언한 쪽은 0x1012에 해당하는 변수가 없는가? 있다. 하지만 익명 변수이고 컴파일러가 알아서 처리하는 숨겨진 변수여서 프로그래머가 접근할 수 없을 뿐이다.
  1. 하지만 같은 프로세스의 다른 스레드는 건드릴 수 있다. 일반적으로 부모자식간이 아닌 스레드 사이에서 메모리 직접 참조가 발생하면 버그다. 하지만 당장 에러가 나진 않고 나중에 엉뚱한 지점에서 폭발한다. 프로그래머의 주적 of 주적.
  2. 그래서 sizeof(int)는 4인데(32비트) sizeof(int *)는 8이다(64비트)