모두의 코드
씹어먹는 C++ - <2. C++ 참조자(레퍼런스)의 도입>

작성일 : 2012-01-01 이 글은 45790 번 읽혔습니다.

이번 강좌에서는

  • 기초적인 함수의 사용

  • C++ 레퍼런스(reference, 참조자) 의 도입

  • 레퍼런스 배열과 배열의 레퍼런스

안녕하세요 여러분! 오랜만에 찾아온 Psi 입니다. 사실 이전 강좌에서 부터 강조해왔지만 C 언어에서 되던 것이 C++ 에서는 거의 100% 된다고 보셔도 무방합니다.

즉 기초적인 문법이 거의 똑같다는 것이지요. 이전 강좌에서는 기본적인 구문들, 예를 들어 변수의 정의 방법이나, 조건문(if, else, switch), 반복문(for, while, do-while) 등등을 살펴 보았는데요, 이번 강좌에서는 C++ 에 새로 도입된 새로운 개념인 참조자 (혹은 레퍼런스라고도 많이 합니다.) 에 대해서 다루어 볼 것입니다.

참조자의 도입

#include <iostream>

int change_val(int *p) {
  *p = 3;

  return 0;
}
int main() {
  int number = 5;

  std::cout << number << std::endl;
  change_val(&number);
  std::cout << number << std::endl;
}

성공적으로 컴파일 하였다면

실행 결과

5
3

와 같이 나옵니다.

저의 C 언어 강좌를 잘 따라오신 분이라면 위 코드를 무리없이 이해하실 수 있을 것입니다.

change_val 함수의 인자 pnumber 의 주소값을 전달하여, *p 를 통해 number 를 참조하여 number 의 값을 3 으로 바꾸었습니다.

C 언어에서는 어떠한 변수를 가리키고 싶을 땐 반드시 포인터를 사용해야만 했습니다. 그런데 C++ 에서는 다른 변수나 상수를 가리키는 방법으로 또 다른 방식을 제공하는데, 이를 바로 참조자(레퍼런스 - reference) 라고 부릅니다.

#include <iostream>

int main() {
  int a = 3;
  int& another_a = a;

  another_a = 5;
  std::cout << "a : " << a << std::endl;
  std::cout << "another_a : " << another_a << std::endl;

  return 0;
}

성공적으로 컴파일 하였다면

실행 결과

a : 5
another_a : 5

와 같이 나옵니다.

int a = 3;

먼저 우리는 위와 같이 간단히 int 형 변수인 a 를 정의하였고 그 안에 3 이란 값을 넣어주었습니다.

int& another_a = a;

그 후에 우리는 a 의 참조자 another_a 를 정의하였습니다. 이 때 참조자를 정하는 방법은, 가리키고자 하는 타입 뒤에 & 를 붙이면 됩니다.

위 처럼 int 형 변수의 참조자를 만들고 싶을 때에는 int& 를, double 의 참조자를 만드려면 double& 로 하면 됩니다. 심지어 int* 와 같은 포인터 타입의 참조자를 만드려면 int*& 로 쓰면 됩니다.

위와 같이 선언함으로써 우리는 another_aa 의 참조자다! 라고 공표하게 되었습니다. 이 말은 즉슨 another_aa또다른 이름 이라고 컴파일러에게 알려주는 것입니다. 따라서 another_a 에 어떠한 작업을 수행하든 이는 사실상 a 에 그 작업을 하는 것과 마찬가지 입니다.

another_a = 5;
std::cout << "a : " << a << std::endl;
std::cout << "another_a : " << another_a << std::endl;

따라서 위 처럼 another_a 에 5 를 대입하였지만 실제로 a 의 값을 확인해보면 5 로 바뀌었음을 확인할 수 있습니다.

어뜨게 보면 참조자와 포인터는 상당히 유사한 개념입니다. 포인터 역시 다른 어떤 변수의 주소값을 보관함으로써 해당 변수에 간접적으로 연산을 수행할 수 있기 때문이죠. 하지만 레퍼런스와 포인터는 몇 가지 중요한 차이점이 있습니다.

레퍼런스는 반드시 처음에 누구의 별명이 될 것인지 지정해야 합니다.

레퍼런스는 정의 시에 반드시 누구의 별명인지 명시 해야 합니다. 따라서

int& another_a;

와 같은 문장은 불가능 합니다. 반면의 포인터의 경우

int* p;

는 전혀 문제가 없는 코드 입니다.

레퍼런스가 한 번 별명이 되면 절대로 다른 이의 별명이 될 수 없다.

레퍼런스의 또 한 가지 중요한 특징으로 한 번 어떤 변수의 참조자가 되버린다면, 이 더이상 다른 변수를 참조할 수 없게 됩니다.

예를 들어서

int a = 10;
int &another_a = a; // another_a 는 이제 a 의 참조자!

int b = 3;
another_a = b; // ?? 

아래와 같은 코드를 살펴봅시다. 마지막에 another_a = b; 문장은 어떤 의미 일까요? another_a 보고 다른 변수인 b 를 가리키라고 하는 것일까요? 아닙니다! 이는 그냥 ab 의 값을 대입하라는 의미 입니다. 앞서 말했듯이 another_a 에 무언가를 하는 것은 사실상 a 에 무언가를 하는 것과 동일하다고 했으므로 이 문장은 그냥 a = b 와 동치 입니다.

참고로

&another_a = b;

요건 어떤가요? 라고 물어보실 수 도 있는데 위 문장은 그냥 &a = b; 가 되어서 말이 안되는 문장이 됩니다.

반면에 포인터는 어떨까요.

int a = 10;
int* p = &a; // p 는 a 를 가리킨다.

int b = 3;
p = &b // 이제 p 는 a 를 버리고 b 를 가리킨다

위와 같이 누구를 가리키는지 자유롭게 바뀔 수 있습니다.

레퍼런스는 메모리 상에 존재하지 않을 수 도 있다.

포인터의 경우를 생각해봅시다. 우리가 아래와 같이 포인터 p 를 정의 한다면

int a = 10;
int* p = &a;  // p 는 메모리 상에서 당당히 8 바이트를 차지하게 됩니다.

p 는 당당히 메모리 상에서 8 바이트를 차지하는 녀석이 됩니다 (물론 32 비트 시스템에서는 4바이트 겠죠!) 그런데 레퍼런스의 경우를 생각해봅시다.

int a = 10;
int &another_a = a; // another_a 가 자리를 차지할 필요가 있을까?

만일 내가 컴파일러라면 another_a 위해서 메모리 상에 공간을 할당할 필요가 있을까요? 아니죠! 왜냐하면 another_a 가 쓰이는 자리는 모두 a 로 바꿔치기 하면 되니까요. 따라서 이 경우 레퍼런스는 메모리 상에 존재하지 않게 됩니다. 물론 그렇다고 해서 항상 존재하지 않은 것은 아닙니다. 아래 예제를 보실까요.

함수 인자로 레퍼런스 받기

#include <iostream>

int change_val(int &p) {
  p = 3;

  return 0;
}
int main() {
  int number = 5;

  std::cout << number << std::endl;
  change_val(number);
  std::cout << number << std::endl;
}

성공적으로 컴파일 하였다면

실행 결과

5
3

위 코드는 앞서 포인터를 사용해서 numberchange_val 안에 전달한 코드를 참조자를 이용해서 바꿔본 것입니다.

먼저 가장 중요한 부분으로

int change_val(int &p) {

와 같이 함수이 인자로 참조자를 받게 하였습니다. 여기서

아까 int& p 는 안된다고 하지 않으셨나요?

라고 물을 수 있는데 사실 p 가 정의되는 순간은 change_val(number) 로 호출할 때 이므로 사실상 int& p = number 가 실행된다고 생각하면 됩니다. 따라서 전혀 문제가 없죠.

change_val(number);

아무튼 위와 같이 참조자 p 에게 너는 앞으로 number 의 새로운 별명이야 라고 알려주게 됩니다. 여기서 중요한 점은 포인터가 인자일 때와는 다르게 number 앞에 & 를 붙일 필요가 없다는 점입니다. 이는 참조자를 정의할 때 그냥 int& a = b 와 같이 한 것과 일맥상통합니다.

int change_val(int &p) {
  p = 3;

  return 0;
}

그 후 change_val 안에서 p = 3; 이라 하는 것은 main 함수의 numbernumber = 3; 을 하는 것과 정확히 같은 작업입니다.

자 보세요. 어느 방식이 좀 더 깔끔하신 것 같나요?

여러가지 참조자 예시들

// 참조자 이해하기

#include <iostream>

int main() {
  int x;
  int& y = x;
  int& z = y;

  x = 1;
  std::cout << "x : " << x << " y : " << y << " z : " << z << std::endl;

  y = 2;
  std::cout << "x : " << x << " y : " << y << " z : " << z << std::endl;

  z = 3;
  std::cout << "x : " << x << " y : " << y << " z : " << z << std::endl;
}

성공적으로 컴파일 하였다면

실행 결과

x : 1 y : 1 z : 1
x : 2 y : 2 z : 2
x : 3 y : 3 z : 3

예상하고 계셨던 결과 인가요?

int x;
int& y = x;

먼저 위와 같이 x 의 참조자로 y 를 정의하였습니다. 이제 yx 의 또다른 별명이 됩니다.

int& z = y;

그렇다면 다음 문장을 봅시다. 간혹 아래와 같이 고개를 갸우뚱 할 수 도 있습니다.

아까 어떤 타입 T 의 참조자 타입은 T& 래매. 그런데 여기서 yint& 니까 y 의 참조자 타입은 int&& 가 되야 하지 않을까?

좋은 질문 입니다. 하지만 참조자의 참조자 라는 말의 의미를 생각해보면 사실 말이 안된다는 것을 알 수 있습니다. 굳이 별명의 별명을 만들 필요는 없으니까요! 실제로 C++ 문법 상 참조자의 참조자를 만드는 것은 금지되어 있습니다.

int& z = y;

즉, 위 문장은 결국 x 의 참조자를 선언해라와 같은 의미가 되서, z 역시 x 의 참조자가 될 것입니다. 따라서 yz 모두 x 의 참조자가 됩니다.

x = 1;
std::cout << "x : " << x << " y : " << y << " z : " << z << std::endl;

y = 2;
std::cout << "x : " << x << " y : " << y << " z : " << z << std::endl;

z = 3;
std::cout << "x : " << x << " y : " << y << " z : " << z << std::endl;

결과적으로 위 문장들은 모두 1,1,12,2,23,3,3 을 출력합니다.

아무래도 처음에 참조자를 접하시는 분들은 왜 굳이 포인터로 할 수 있는 것을 왜 참조자로 해야 하냐고 물을 수 있습니다. 하지만 참조자를 사용하게 되면 불필요한 &* 가 필요 없기 때문에 코드를 훨씬 간결하게 나타낼 수 있습니다.

예를 들어서 지난 강좌에서 변수 입력시 배웠던 cin 을 기억하시나요? 아마 사용자로 부터 변수에 값을 입력 받을 때 다음과 같이 했었을 것입니다.

std::cin >> user_input;

그런데 무언가 이상하지 않으세요? 예전에 scanf 로 이용할 때 분명히

scanf("%d", &user_input);

와 같이 항상 주소값을 전달해 주었는데 말이죠. 왜냐하면 어떤 변수의 값을 다른 함수에서 바꾸기 위해서는 항상 포인터로 주소값을 전달해야하기 때문이니까요. 하지만 여기서는 cin 이라는 것에 그냥 user_input 을 전달했는데 잘 작동합니다.

왜 그럴까요? 바로 cin 이 레퍼런스로 user_input 을 받아서 그렇습니다. 따라서 구질 구질하게 &user_input 앞에 붙일 필요가 없게 되는 것입니다.

상수에 대한 참조자

#include <iostream>

int main() {
  int &ref = 4;

  std::cout << ref << std::endl;
}

위와 같은 소스를 살펴봅시다. 일단 컴파일 해보면 아래와 같은 오류가 나타날 것입니다.

컴파일 오류

error C2440: 'initializing' : cannot convert from 'int' to 'int &'

왜 오류가 나타날까요? 아마 여러분들은 다 알고 계시겠지요. 위 상수 값 자체는 리터럴 이기 때문에 (리터럴이 무엇인지 모르겠으면 여기로) 만일 위와 같이 레퍼런스로 참조한다면

ref = 5;

로 리터럴의 값을 바꾸는 말도 안되는 행위가 가능하게 됩니다. 따라서 C++ 문법 상 상수 리터럴을 일반적인 레퍼런스가 참조하는 것은 불가능하게 되어 있습니다.

물론 그 대신에;

const int &ref = 4;

상수 참조자로 선언한다면 리터럴도 참조 할 수 있습니다. 따라서

int a = ref;

a = 4; 와는 문장과 동일하게 처리됩니다

레퍼런스의 배열과 배열의 레퍼런스

먼저 레퍼런스의 배열이 과연 가능한 것인지에 대해 부터 생각해봅시다. 앞서 말했듯이 레퍼런스는 반드시 정의와 함께 초기화를 해주어야 한다고 했습니다. 따라서 여러분의 머리속에는 다음과 같이 레퍼런스의 배열을 정의하는 것을 떠올렸을 것입니다.

int a, b;
int& arr[2] = {a, b};

컴파일을 해보면

컴파일 오류

error C2234: 'arr' : arrays of references are illegal

레퍼런스의 배열을 불법(illegal) 이라고 합니다. 왜 불법인지 한 번 C++ 규정을 찾아 보면, 표준안 8.3.2/4 를 보면 놀랍게도

There shall be no references to references, no arrays of references, and no pointers to references

레퍼런스의 레퍼런스,레퍼런스의 배열, 레퍼런스의 포인터는 존재할 수 없다.

정말로 언어 차원에서 불가능 하다고 못 박아버렸습니다. 그러면 도대체 왜 안될까요? 왠지 위에서

int& arr[2] = {a, b};

로 해서 arr[0]a 를 의미하고 arr[1]b 를 의미하고.. 로 만들면 안될까요.

이와 같은 주장을 하기 전에 먼저 C++ 상에서 배열이 어떤 식으로 처리되는지 생각해봅시다. 문법 상 배열의 이름은 (arr) 첫 번째 원소의 주소값으로 변환이 될 수 있어야 합니다. 이 때문에 arr[1] 과 같은 문장이 *(arr + 1) 로 바뀌어서 처리될 수 있기 때문이죠.

그런데 주소값이 존재한다라는 의미는 해당 원소가 메모리 상에서 존재한다 라는 의미와 같습니다. 하지만 레퍼런스는 특별한 경우가 아닌 이상 메모리 상에서 공간을 차지 하지 않습니다. 따라서 이러한 모순 때문에 레퍼런스들의 배열을 정의하는 것은 언어 차원에서 금지가 되어 있는 것입니다.

그렇다고 해서 그와 반대인 배열들의 레퍼런스 가 불가능 한 것은 아닙니다.

#include <iostream>

int main() {
  int arr[3] = {1, 2, 3};
  int(&ref)[3] = arr;

  ref[0] = 2;
  ref[1] = 3;
  ref[2] = 1;

  std::cout << arr[0] << arr[1] << arr[2] << std::endl;
  return 0;
}

성공적으로 컴파일 하였다면

실행 결과

231

먼저 가장 중요한 첫 두줄을 살펴봅시다.

int arr[3] = {1, 2, 3};
int (&ref)[3] = arr;

위와 같이 refarr 를 참조하도록 하였습니다.

따라서 ref[0] 부터 ref[2] 가 각각 arr[0] 부터 arr[2] 의 레퍼런스가 됩니다. 포인터와는 다르게 배열의 레퍼런스의 경우 참조하기 위해선 반드시 배열의 크기를 명시해야 합니다.

따라서 int (&ref)[3] 이라면 반드시 크기가 3 인 int 배열의 별명이 되어야 하고 int (&ref)[5] 라면 크기가 5 인 int 배열의 별명이 되어야 합니다.

int arr[3][2] = {1, 2, 3, 4, 5, 6};
int (&ref)[3][2] = arr;

역시 일차원 배열을 했을 때와 동일합니다.

레퍼런스를 리턴하는 함수

먼저 아래 코드를 살펴봅시다.

int function() {
  int a = 2;
  return a;
}

int main() {
  int b = function();
  return 0;
}

아마 여기 까지 따라 오신 분들이라면 무리 없이 이해할 수 있겠죠. 제가 주목하고 싶은 부분은 바로 이 부분 입니다.

int b = function();

여기서 무슨 일이 일어났을까요?

이미 잘 아시겠지만, function 안에 정의된 a 라는 변수의 값이 b복사 되었습니다. 여기서 주목할 점은 복사 되었다는 점입니다.

function 이 종료되고 나면 a 는 메모리에서 사라지게 됩니다. 따라서 더 이상 main 안에서는 a 를 만날 길이 없습니다.

지역변수의 레퍼런스를 리턴?

그 다음 예시를 살펴봅시다.

int& function() {
  int a = 2;
  return a;
}

int main() {
  int b = function();
  b = 3;
  return 0;
}

만일 컴파일 한다면 아래와 같은 경고가 나오고 (컴파일 오류는 아닙니다.)

컴파일 오류

test.cc: In function ‘int& function()’:
test.cc:3:10: warning: reference to local variable ‘a’ returned [-Wreturn-local-addr]
    3 |   return a;
      |          ^
test.cc:2:7: note: declared here
    2 |   int a = 2;
      |       ^

실제로 실행해보면

실행 결과

[1]    7170 segmentation fault (core dumped)  ./test

위와 같이 런타임 오류가 발생하게 되었습니다.

과연 뭐가 문제였을까요?

int& function() {
  int a = 2;
  return a;
}

function 의 리턴 타입은 int& 입니다. 따라서 참조자를 리턴하게 됩니다. 그런데 문제는 리턴하는 function 안에 정의되어 있는 a 는 함수의 리턴과 함께 사라진다는 점입니다.

int b = function();

위 문장은 사실상

int& ref = a;

// 근데 a 가 사라짐
int b = ref;  // !!!

와 같은 의미 인데, function 이 레퍼런스를 리턴하면서 원래 참조하고 있던 변수가 이미 사라져버렸으므로 오류가 발생하게 됩니다. 쉽게 말해 본체는 이미 사라졌지만 별명만 남아 있는 상황입니다.

이와 같이 레퍼런스는 있는데 원래 참조 하던 것이 사라진 레퍼런스를 댕글링 레퍼런스 (Dangling reference) 라고 부릅니다. Dangling 이란 단어의 원래 뜻은 약하게 결합대서 달랑달랑 거리는 것을 뜻하는데, 레퍼런스가 참조해야 할 변수가 사라져서 혼자서 덩그러니 남아 있는 상황과 유사하다고 보시면 됩니다.

주의 사항

따라서 위 처럼 레퍼런스를 리턴하는 함수에서 지역 변수의 레퍼런스를 리턴하지 않도록 조심해야 합니다.

외부 변수의 레퍼런스를 리턴

그렇다면 이 경우는 어떨까요?

int& function(int& a) {
  a = 5;
  return a;
}

int main() {
  int b = 2;
  int c = function(b);
  return 0;
}

function 역시 레퍼런스를 리턴하고 있습니다. 하지만 아까와의 차이점은

int& function(int& a) {
  a = 5;
  return a;
}

위와 같이 인자로 받은 레퍼런스를 그대로 리턴 하고 있습니다.

function(b) 를 실행한 시점에서 amainb 를 참조하고 있게 됩니다. 따라서 function 이 리턴한 참조자는 아직 살아있는 변수인 b 를 계속 참조 합니다.

int c = function(b);

결국 위 문장은 그냥 c 에 현재의 b 의 값인 5 를 대입하는 것과 동일한 문장이 됩니다.

그렇다면 이렇게 참조자를 리턴하는 경우의 장점이 무엇일까요? C 언어에서 엄청나게 큰 구조체가 있을 때 해당 구조체 변수를 그냥 리턴하면 전체 복사가 발생해야 해서 시간이 오래걸리지만, 해당 구조체를 가리키는 포인터를 리턴한다면 그냥 포인터 주소 한 번 복사로 매우 빠르게 끝납니다.

마찬가지로 레퍼런스를 리턴하게 된다면 레퍼런스가 참조하는 타입의 크기와 상관 없이 딱 한 번의 주소값 복사로 전달이 끝나게 됩니다. 따라서 매우 효율적이죠!

참조자가 아닌 값을 리턴하는 함수를 참조자로 받기

이번에는 반대로 함수가 값을 리턴하는데 참조자로 받는 경우를 생각해봅시다.

int function() {
  int a = 5;
  return a;
}

int main() {
  int& c = function();
  return 0;
}

컴파일 하였다면 아래와 같은 오류가 발생합니다.

실행 결과

test.cc: In function ‘int main()’:
test.cc:7:20: error: cannot bind non-const lvalue reference of type ‘int&’ to an rvalue of type ‘int’
    7 |   int& c = function();
      |            ~~~~~~~~^~

컴파일 오류를 읽어보면 상수가 아닌 레퍼런스가 function 함수의 리턴값을 참조할 수 없다는 의미가 되겠습니다.

int& c = function();

cfunction 의 리턴값을 참조할 수 없는 것일까요? 이는 아까전 상황과 마찬가지로 함수의 리턴값은 해당 문장이 끝난 후 바로 사라지는 값이기 때문에 참조자를 만들게 되면 바로 다음에 댕글링 레퍼런스가 되버리기 때문입니다. 따라서 만약에

int& c = function();
c = 2;

와 같은 작업을 하게 된다면 앞서 보았던 런타임 오류를 보시게 될 것입니다.

하지만 C++ 에서 중요한 예외 규칙이 있습니다. 바로 다음 코드를 살펴보시죠.

#include <iostream>

int function() {
  int a = 5;
  return a;
}

int main() {
  const int& c = function();
  std::cout << "c : " << c << std::endl;
  return 0;
}

성공적으로 컴파일 하였다면

실행 결과

c : 5

와 같이 나옵니다.

const int& c = function();

이번에도 역시 function() 의 리턴값을 참조자로 받았습니다. 그런데, 이 const 참조자로 받았더니 문제없이 컴파일 되었습니다.

std::cout << "c : " << c << std::endl;

그리고 심지어 그 리턴값도 제대로 출력됩니다.

원칙상 함수의 리턴값은 해당 문장이 끝나면 소멸되는 것이 정상입니다. 따라서 기존에 int& 로 받았을 때에는 컴파일 자체가 안되었습니다. 하지만 예외적으로 상수 레퍼런스로 리턴값을 받게 되면 해당 리턴값의 생명이 연장됩니다. 그리고 그 연장되는 기간은 레퍼런스가 사라질 때 까지 입니다.

이번 강좌에서 다룬 것이 상당히 많은 데 간단히 정리해보자면 다음과 같습니다.

함수에서 값 리턴 (int f())함수에서 참조자 리턴 (int& f())
값 타입으로 받음(int a = f())값 복사됨값 복사됨. 다만 지역 변수의 레퍼런스를 리턴하지 않도록 주의
참조자 타입으로 받음 (int& a = f())컴파일 오류가능. 다만 마찬가지로 지역 변수의 레퍼런스를 리턴하지 않도록 주의
상수 참조자 타입으로 받음 (const int& a = f())가능가능. 다만 마찬가지로 지역 변수의 레퍼런스를 리턴하지 않도록 주의

자 이렇게 C++ 상에서 레퍼런스를 사용하는 방법에 대해서 간단히 다루어보었습니다. 다음 강좌에서는 본격적으로 C++ 의 객체 지향 프로그래밍 개념에 대해서 다루어보겠습니다.

생각해보기

문제 1

레퍼런스가 메모리 상에 반드시 존재해야 하는 경우는 어떤 경우가 있을까요? 그리고 메모리 상에 존재할 필요가 없는 경우는 또 어떤 경우가 있을 까요? (난이도 : 上)

강좌를 보다가 조금이라도 궁금한 것이나 이상한 점이 있다면 꼭 댓글을 남겨주시기 바랍니다. 그 외에도 강좌에 관련된 것이라면 어떠한 것도 질문해 주셔도 상관 없습니다. 생각해 볼 문제도 정 모르겠다면 댓글을 달아주세요.

현재 여러분이 보신 강좌는 <2. C++ 참조자(레퍼런스)의 도입> 입니다. 이번 강좌의 모든 예제들의 코드를 보지 않고 짤 수준까지 강좌를 읽어 보시기 전까지 다음 강좌로 넘어가지 말아주세요
댓글이 81 개 있습니다!
프로필 사진 없음
강좌에 관련 없이 궁금한 내용은 여기를 사용해주세요