C++ 공식 웹사이트인 isocpp.org에 FAQ로 아래와 같은 유명한 말이 있습니다.
Q. When should I use references, and when should I use pointers?
(언제 참조자를 쓰고 언제 포인터를 써야 할까요?)
A. Use references when you can, and pointers when you have to.
(가능하면 참조자를 쓰고 필요하다면 포인터를 쓰십시오.)
이처럼 참조자(Reference)는 C++를 사용한다면 반드시 익혀야 하는 매우 중요한 개념입니다. 하지만 구조적으로 포인터와 유사하여 혼동이 생기기 쉬운 개념이기도 하죠.
그럼 아래 목차대로 참조자에 대해 하나하나 알아보도록 합시다.
목차 (클릭 시 이동)
1. 참조자(Reference)의 종류
- 비-상수 참조(non-const reference): 보통 참조자라고 하면 이것을 말합니다.
- 상수 참조(const reference): 상수를 참조하기 위한 것이지만 비-상수도 참조가 가능합니다.
- 우측 값 참조(rvalue reference): 이동 생성자와 관련이 있기 때문에 여기서는 논외로 하겠습니다.
2. 참조자(Reference)와 규칙
참조자란 대상에게 또 다른 이름을 붙여주는 것을 말합니다. 별명 또는 별칭을 붙여준다고도 하죠.
#include <iostream>
int main(void)
{
int num = 1;
int& ref = num;
ref = 2;
std::cout << "num value: " << num << std::endl; // num value: 2
std::cout << "ref value: " << ref << std::endl; // ref value: 2
std::cout << "num address: " << &num << std::endl; // num address와
std::cout << "ref address: " << &ref << std::endl; // ref address는 동일
return 0;
}
int num = 1;
은 메모리(스택) 특정 영역에 int
만큼의 크기를 할당하고 1
이라는 데이터를 복사하는 것입니다. 그리고 할당된 메모리의 이름은 num
이 되는데 이것을 변수라고 합니다.
int& ref = num;
은 num
을 참조하는 참조자 ref
를 선언하는 것입니다. 그럼 이 변수의 이름은 num
뿐만 아니라 ref
도 되는 것이죠. 즉, 하나의 메모리 공간에 이름이 2개 생기게 되고 이 둘의 주소 값이 같다라는 의미입니다. (하나의 대상을 두고 2개가 아니라 그 이상으로도 참조자를 만들 수 있습니다.)
ref = 2;
를 하고 값과 주소를 모두 출력해보면 num
과 ref
모두 2
로 변경되고 주소가 같은 것도 확인할 수 있습니다.
2.1) 선언 방법
int num = 1;
int& ref1 = num; // 참조자 선언
int& ref2; // Compile Error: 초기화 필요
참조자를 선언하는 방법은 선언 시 &
연산자를 붙여야 하며 선언과 동시에 참조할 대상을 지정하여 초기화해야 합니다.
2.3) 참조 대상 변경 불가
int num1 = 1;
int num2 = 2;
int& ref = num1; // ref와 num1은 같음
ref = num2; // num2를 참조하는 것이 아니라 num1 = num2;와 같은 의미
한 번 누군가를 참조했으면 참조의 대상을 바꿀 수 없습니다. ref
가 num1
을 참조한 상태에서 ref = num2;
를 해도 ref
가 num2
를 참조하는 것이 아니라 num1 = num2;
를 하는 것입니다.
2.4) 상수 참조
const int num = 10; // 상수
int& ref1 = num; // Compile Error: 비-상수 참조자로 상수 참조 불가
const int& ref2 = num; // Pass
const int& ref3 = 100; // Pass
비-상수 참조자(non-const reference)는 상수를 참조할 수 없지만 const
를 사용하면 가능합니다.
또한 const int& ref3 = 100;
처럼 리터럴 상수도 참조할 수 있습니다. 여기서 100
이라는 리터럴 상수는 임시적인 값으로써 메모리에 이름도 없이 존재 했다가 다음 행에서 소멸하는 것을 말합니다. 때문에 non-const 참조자는 상수를 참조할 수 없습니다. 메모리에서 이름도 없이 바로 사라지는 임시 값이니까요.
하지만 위와 같이 상수를 const 참조자로 참조하면 '임시 변수'라는 것이 생기면서 참조가 가능합니다.
"그렇다면 왜 굳이 const 참조자까지 사용하면서 상수를 참조할까요?"
#include <iostream>
int Add(const int& num1, const int& num2) // const 참조자형 매개변수
{
return num1 + num2;
}
int main(void)
{
std::cout << Add(1, 2) << std::endl; // 상수를 전달
return 0;
}
Add
함수의 매개변수를 const 참조자로 받고 있습니다. 그러면 Add(1, 2)
처럼 곧바로 상수로 함수 호출이 가능하죠. 즉, const int& num1 = 1; const int& num2 = 2;
가 되는 것입니다.
"하지만 만약 const 참조자가 없다면?"
#include <iostream>
int Add(int& num1, int& num2) // non-const 참조자형 매개변수
{
return num1 + num2;
}
int main(void)
{
int num1 = 1;
int num2 = 2;
std::cout << Add(num1, num2) << std::endl; // 변수를 전달
return 0;
}
위와 같이 그저 상수 값을 더하고 싶은데도 번거롭게 num1
, num2
를 선언하여 변수를 전달해야 합니다.
"그렇다면 참조자는 NULL을 참조할 수 있을까요, 없을까요?"
2.5) NULL 참조 불가
int& ref = NULL; // Compile Error: 비-상수 참조자로 상수 참조 불가
const 참조자에 관해서 설명 드렸으니 알 수 있는 부분이죠? NULL
은 상수 값 0
을 define
해 놓은 것이므로 NULL
과 0
은 같습니다. 따라서 일반 참조자는 NULL 참조가 불가능합니다.
이것이 포인터와의 차이점 중 하나이기도 하죠. 참조자의 매개변수나 반환으로 함수를 다루는 경우 NULL Check를 꼼꼼하게 하지 않으면 런타임 에러의 위험이 있습니다.
3. 참조자(Reference)와 함수
3.1) 참조자가 매개변수인 함수
#include <iostream>
void SwapByPTR(int* arg1, int* arg2)
{
int tmp = *arg1;
*arg1 = *arg2;
*arg2 = tmp;
}
void SwapByREF(int& arg1, int& arg2)
{
int tmp = arg1;
arg1 = arg2;
arg2 = tmp;
}
int main(void)
{
int num1 = 1;
int num2 = 2;
int num3 = 3;
int num4 = 4;
SwapByPTR(&num1, &num2);
std::cout << num1 << " " << num2 << std::endl; // 2 1
SwapByREF(num3, num4);
std::cout << num3 << " " << num4 << std::endl; // 4 3
return 0;
}
"값에 의한 호출, 참조에 의한 호출(Call By Value, Call By Reference)"에 대해 많이 들어보셨을 것입니다. C에서 참조에 의한 호출은 포인터를 활용하는 방법 밖에 없었지만, C++에서는 참조자로도 "참조에 의한 호출"을 할 수 있습니다.
(포인터 Swap 함수 설명은 생략하고) SwapByREF
는 참조자를 매개변수로 받고 있습니다. 그래서 함수 호출 시 num3
, num4
를 그대로 전달하면 함수 내부에서 arg1
, arg2
를 Swap 하는 것이지만 실제로는 arg1
, arg2
가 참조하고 있는 num3
, num4
가 Swap 되는 것이죠.
"그럼 참조자를 이용한 함수 호출의 장점은 무엇일까요?"
#include <iostream>
void PrintNum(const int arg) // 매개변수에 const를 붙여 의도치 않은 변형을 방지
{
std::cout << arg << std::endl;
}
int main(void)
{
int num = 1;
PrintNum(num); // const int arg = num;
return 0;
}
PrintNum
함수를 호출할 때 num
을 매개변수로 전달하는 것은 const int arg = num;
과 동일한 의미입니다. 즉, num
크기 만큼 메모리 재할당이 일어나는 것입니다. 우리는 그저 num
의 값만 전달하고 싶었는데 말이죠.
그런데 만약 전달하는 데이터가 int
형이 아니라 엄청나게 큰 용량의 객체라면? 그만큼 거대한 용량의 메모리를 재할당 해야 하기 때문에 메모리가 낭비 되는 것입니다.
#include <iostream>
void PrintNum(const int& arg) // 매개변수에 const를 붙여 의도치 않은 변형을 방지
{ // 매개변수를 참조자로 변경
std::cout << arg << std::endl;
}
int main(void)
{
int num = 1;
PrintNum(num); // const int& arg = num;
return 0;
}
이 경우 PrintNum
함수의 매개변수를 값이 아니라 참조자로 받으면 해결됩니다. 그러면 num
의 동일한 메모리에 arg
라는 또 다른 이름만 부여하는 것이기 때문에 메모리 재할당이 없는 것이죠.
사실 이렇게 메모리 재할당을 방지하는 것은 포인터로도 동일하게 할 수 있습니다. 다만, 참조자는 포인터와 달리 주소값 연산을 이용하여 해당 변수의 메모리가 아닌 다른 메모리에 접근할 수가 없기 때문에 안전하다는 장점이 있습니다.
그런데 참조자의 근본적인 문제점이 하나 있습니다.
int num1 = 1;
int num2 = 2;
DoSomething(num1, num2);
DoSomething
이 끝나면 num1
, num2
의 값은 어떻게 될까요? 답은 '모른다'입니다. 함수 호출자 입장에서는 이 함수가 매개변수로 일반 변수를 받는지, 참조자를 받는지 알 수 없습니다. 이 경우 C++ 언어적 차원에서 근본적인 해결 방안은 현재까지 없습니다.
따라서 함수 작성자는 함수의 매개변수가 참조자이고 이 매개변수가 변형될 일이 없다면 매개변수를 const 참조자로 작성해야 하며, 함수 호출자 또한 함수가 매개변수를 어떤 데이터형으로 받는지 확인해야 합니다.
3.2) 반환형이 참조자인 함수
#include <iostream>
int AddNum(int& arg) // 값 반환
{
arg++;
return arg;
}
int main(void)
{
int num1 = 1;
int num2 = AddNum(num1); // num1과 num2는 별개의 변수가 됨
num1++;
std::cout << num1 << std::endl; // 3
std::cout << num2 << std::endl; // 2
return 0;
}
AddNum
함수는 매개변수를 참조자로 받고 int
형 값을 반환하고 있습니다. int num2 = AddNum(num1);
은 int num2 = 2;
와 마찬가지인 것이죠. (num1
은 1
로 초기화 되고 AddNum
에서 2
가 되었으니까요.)
따라서 num1
과 num2
는 완전히 별개의 변수가 된 것입니다. 결과를 출력해보면 알 수 있죠.
이번엔 참조자를 반환해봅시다.
#include <iostream>
int& AddNum(int& arg) // 참조자 반환
{
arg++;
return arg;
}
int main(void)
{
int num1 = 1;
int num2 = AddNum(num1); // num1과 num2는 별개의 변수가 됨
num1++;
std::cout << num1 << std::endl; // 3
std::cout << num2 << std::endl; // 2
int& num3 = AddNum(num1); // 참조자로 받았기 때문에 num1과 num3은 같음
num1++;
std::cout << num1 << std::endl; // 5
std::cout << num3 << std::endl; // 5
return 0;
}
13번째 줄을 보시면 일반 변수로 참조자를 반환 받고 있는데 당연히 가능한 일입니다. num2
에 arg
의 값만 복사가 되는 것이죠. 따라서 num1
, num2
는 마찬가지로 별개의 변수가 됩니다.
20번째 줄을 보시면 이번엔 참조자로 참조자를 반환 받고 있습니다. 즉, AddNum(num1)
을 했을 때 num1
에 또 다른 이름인 arg
가 붙었었는데 이번에도 또 다른 이름인 num3
이 붙은 것입니다. 이는 int& num3 = num1;
이 되는 것이고 따라서 num1
, num3
은 같은 메모리 위치에 있는 것이죠.
그런데 함수에서 참조자를 반환할 때 주의해야 하는 부분이 있습니다.
#include <iostream>
int& GetNum()
{
int ret = 1;
return ret; // Compile Warning
}
int main(void)
{
int& num = GetNum();
num = 2; // Runtime Error
return 0;
}
GetNum
함수의 반환형이 참조자인데 지역 변수를 반환하고 있습니다. 이렇게 되면 num
은 ret
를 참조하고 싶은데 ret
은 지역 변수이기 때문에 GetNum
함수가 끝나면서 메모리에서 소멸하게 됩니다. 그러면 소멸된 메모리를 참조하려는 오류를 범하는 것이죠.
그래서 ret
을 반환하는 부분에서 컴파일 경고가 발생합니다. (컴파일 에러가 아닙니다.) 그리고 num
에 접근하려고 하면 런타임 에러가 발생하게 되죠. 이렇게 해제된 메모리를 참조하는 참조자를 댕글링 레퍼런스(Dangling Reference)라고 합니다. 함수에서 참조자를 반환할 때는 지역 변수를 반환하여 댕글링 레퍼런스가 발생하지 않았는지 각별히 주의하여야 합니다.
여기까지 참조자란 무엇인지, 어떤 규칙들이 있는지, 함수의 매개변수와 반환형으로 어떻게 활용 되는지, 그리고 어떠한 방식으로 사용해야 하고 무엇을 조심해야 하는지 등을 알아 보았습니다.
틀린 부분이나 궁금한 점이 있으시면 댓글 남겨주시기 바랍니다. 감사합니다! :)
'C++ > 강좌' 카테고리의 다른 글
[C++ 강좌] const 위치의 의미와 사용 방법 (5) | 2021.01.25 |
---|---|
[C++ 강좌] 상수 선언 방법 #define, const, enum, enum class (3) | 2021.01.20 |
댓글