앞선 글에서는 포인터에 변수의 메모리 주소를 저장하고, 역참조 연산자 *를 사용해 원래 값에 접근하는 방법을 알아보았습니다.
int number = 5; int* pointer = &number; *pointer = 10;
C++에서는 레퍼런스(Reference)를 사용해 기존 변수에 직접 접근할 수도 있습니다.
int number = 5; int& reference = number; reference = 10;
레퍼런스는 기존 변수에 새로운 이름을 붙여, 포인터보다 간결한 문법으로 해당 변수에 접근할 수 있게 해주는 도구입니다.
이번 글에서는 레퍼런스의 기본 개념과 선언 방법, 초기화 및 재지정에 관한 제약, 함수 매개변수에서 레퍼런스를 사용하는 방법을 알아보겠습니다.
목차
레퍼런스란?
레퍼런스는 기존 변수의 별칭(Alias)입니다.
별칭은 이미 존재하는 대상을 가리키는 또 다른 이름을 의미합니다.
int number = 5; int& reference = number;
위 코드에서 reference는 새로운 정수 값을 저장하는 독립적인 변수가 아니라, 기존 변수 number를 나타내는 또 다른 이름으로 사용됩니다.
number = 10; std::cout << reference << '\n';
number의 값을 변경한 뒤 reference를 출력하면 같은 변수의 값인 10이 출력됩니다.
반대로 레퍼런스를 통해 값을 변경하면 원래 변수의 값도 변경됩니다.
reference = 20; std::cout << number << '\n';
reference와 number는 서로 다른 값을 저장하는 두 변수가 아니라 같은 객체를 나타내기 때문입니다.
레퍼런스 선언하기
L-value 레퍼런스를 선언할 때는 참조할 데이터의 타입 뒤에 &를 작성합니다.
데이터 타입& 레퍼런스 이름 = 참조할 변수;
정수 변수의 레퍼런스는 다음과 같이 선언할 수 있습니다.
int number = 5; int& reference = number;
reference의 타입은 int&입니다. 이는 int 타입의 객체를 참조하는 L-value 레퍼런스를 의미합니다.
다른 타입의 변수도 같은 방식으로 참조할 수 있습니다.
float speed = 5.0f; float& speedReference = speed; char grade = 'A'; char& gradeReference = grade;
포인터를 선언할 때 사용하는 *와 마찬가지로 &를 타입과 변수 이름 중 어느 쪽에 붙여도 문법상 의미는 같습니다.
int& firstReference = number; int &secondReference = number;
다만 한 문장에서 여러 변수를 선언하면 각 선언자에 &가 개별적으로 적용됩니다.
int& first = number, second = number;
위 코드에서 first는 int&이지만, second는 별도의 int 변수입니다. 혼동을 줄이려면 다음과 같이 선언문을 나누는 것이 좋습니다.
int& first = number; int& second = number;
레퍼런스 타입을 선언할 때 사용하는
&와 표현식에서 변수의 주소를 구할 때 사용하는 주소 연산자&는 기호는 같지만 역할이 다릅니다.
int number = 5; int& reference = number; // 레퍼런스 타입 선언 int* pointer = &number; // number의 주소를 구하는 연산자
레퍼런스로 원래 변수 변경하기
레퍼런스를 통해 값을 변경하면 레퍼런스가 참조하는 원래 변수의 값이 변경됩니다.
#include <iostream>
#define LOG(x) std::cout << x << '\n'
int main()
{
int number = 5;
int& reference = number;
reference = 10;
LOG(number);
LOG(reference);
std::cin.get();
return 0;
}
reference = 10은 별도의 복사본에 값을 저장하는 코드가 아닙니다. reference가 나타내는 원래 변수 number에 10을 저장합니다.
출력 결과
10 10
포인터로 같은 작업을 처리하려면 역참조 연산자가 필요합니다.
int number = 5; int* pointer = &number; *pointer = 10;
레퍼런스를 사용하면 일반 변수와 같은 문법으로 원래 값에 접근할 수 있습니다.
int number = 5; int& reference = number; reference = 10;
| 방식 | 원래 변수의 값 변경 |
|---|---|
| 포인터 | *pointer = 10; |
| 레퍼런스 | reference = 10; |
레퍼런스는 반드시 초기화해야 한다
일반 변수는 선언한 뒤 나중에 값을 대입할 수 있습니다.
int number; number = 10;
포인터도 우선 nullptr로 초기화한 뒤 나중에 유효한 주소를 저장할 수 있습니다.
int* pointer = nullptr; int number = 10; pointer = &number;
하지만 레퍼런스는 선언과 동시에 참조할 대상을 지정해야 합니다.
// int& reference; // 오류
레퍼런스는 기존 객체의 별칭으로 만들어져야 하므로 참조할 대상 없이 선언만 해둘 수 없습니다.
int number = 10; int& reference = number;
일반적인 L-value 레퍼런스에는 nullptr와 같은 비어 있는 상태도 없습니다.
// int& reference = nullptr; // 오류
따라서 레퍼런스를 정상적인 방법으로 초기화했다면 해당 레퍼런스는 유효한 객체를 참조한다고 기대할 수 있습니다.
레퍼런스가 항상 안전하다는 의미는 아닙니다. 참조하던 객체의 수명이 끝난 뒤에도 레퍼런스를 사용하면 댕글링 레퍼런스가 되어 정의되지 않은 동작이 발생할 수 있습니다.
L-value 레퍼런스란?
지금까지 살펴본 int&와 같은 타입을 L-value 레퍼런스라고 합니다.
int number = 10; int& reference = number;
간단히 설명하면 L-value는 메모리에 존재하며 이름을 통해 다시 접근할 수 있는 객체를 나타냅니다.
변수 number는 이름이 있고 여러 표현식에서 반복해서 접근할 수 있으므로 L-value입니다. 따라서 일반적인 int&에 연결할 수 있습니다.
int value = 10; int& reference = value;
반면 정수 리터럴 10은 표현식에서 만들어지는 임시 값이므로 일반적인 int&로 참조할 수 없습니다.
// int& reference = 10; // 오류
지금까지의 제약을 함께 살펴보면 다음과 같습니다.
int main()
{
// int& firstReference; // 오류: 초기화하지 않았다.
// int& secondReference = 10; // 오류: 일반 L-value 레퍼런스로 임시 값을 참조할 수 없다.
int value = 10;
int& valueReference = value;
}
const int&와 같은 상수 L-value 레퍼런스는 임시 값도 참조할 수 있습니다. C++11부터 제공되는 R-value 레퍼런스int&&도 임시 객체와 관련된 작업에 사용됩니다. 이번 글에서는 변경 가능한 일반 L-value 레퍼런스의 기본 동작에 집중합니다.
레퍼런스는 다른 변수를 다시 참조할 수 없다
레퍼런스가 한 번 초기화되면 이후에 다른 변수를 참조하도록 변경할 수 없습니다.
int first = 5; int second = 10; int& reference = first;
위 코드에서 reference는 first의 별칭으로 초기화되었습니다.
이후 다음과 같이 다른 변수를 대입하더라도 참조 대상이 바뀌지는 않습니다.
reference = second;
이 코드는 reference가 second를 새로 참조하도록 만드는 것이 아닙니다.
reference는 계속 first를 나타내므로, second의 값인 10을 first에 대입합니다.
#include <iostream>
int main()
{
int first = 5;
int second = 10;
int& reference = first;
reference = second;
std::cout << first << '\n';
std::cout << second << '\n';
std::cout << reference << '\n';
return 0;
}
출력 결과
10 10 10
first와 second의 값이 모두 10이 되어 출력 결과만으로는 참조 대상을 구분하기 어려울 수 있습니다.
두 변수의 값을 다시 다르게 변경하면 reference가 계속 first를 참조한다는 사실을 확인할 수 있습니다.
first = 20; second = 30; std::cout << reference << '\n'; // 20
포인터는 저장된 주소를 변경해 다른 변수를 가리키게 할 수 있지만, 레퍼런스는 초기화된 뒤 참조 대상을 변경할 수 없습니다.
레퍼런스와 원래 변수의 주소
레퍼런스는 기존 변수의 별칭이므로 레퍼런스에 주소 연산자 &를 적용하면 원래 변수의 주소를 얻습니다.
#include <iostream>
int main()
{
int number = 10;
int& reference = number;
std::cout << &number << '\n';
std::cout << &reference << '\n';
return 0;
}
&number와 &reference는 같은 객체의 주소를 나타내므로 같은 주소가 출력됩니다.
출력 결과 예시
000000A47FD9F684 000000A47FD9F684
실제 주소는 프로그램을 실행할 때마다 달라질 수 있습니다.
컴파일러는 레퍼런스를 구현하기 위해 내부적으로 주소와 유사한 정보를 사용할 수 있습니다. 그러나 C++ 언어의 관점에서는 레퍼런스를 포인터 변수 자체로 다루지 않고, 기존 객체의 별칭으로 이해하는 것이 적절합니다.
포인터와 레퍼런스 비교하기
포인터와 레퍼런스는 모두 다른 객체에 간접적으로 접근할 때 사용할 수 있지만 문법과 제약이 다릅니다.
| 구분 | 포인터 | 레퍼런스 |
|---|---|---|
| 선언 | int* pointer |
int& reference |
| 저장하거나 나타내는 대상 | 객체의 메모리 주소 | 기존 객체의 별칭 |
| 초기화 | nullptr로 초기화 가능 |
선언과 동시에 객체를 지정해야 함 |
| 대상 변경 | 다른 주소를 저장할 수 있음 | 초기화 후 참조 대상 변경 불가 |
| 값 접근 | *pointer로 역참조 |
일반 변수와 같은 문법 사용 |
| 비어 있는 상태 | nullptr로 표현 가능 |
일반적으로 제공되지 않음 |
| 포인터 연산 | 가능 | 레퍼런스 자체에는 적용하지 않음 |
다음 두 코드는 모두 number의 값을 10으로 변경합니다.
int number = 5; int* pointer = &number; *pointer = 10;
int number = 5; int& reference = number; reference = 10;
대상이 반드시 존재하고 도중에 변경되지 않아야 한다면 레퍼런스를 사용해 의도를 명확하게 표현할 수 있습니다.
대상이 없을 수 있거나, 실행 중에 다른 대상을 가리켜야 한다면 포인터가 더 적합할 수 있습니다.
값에 의한 전달
함수의 일반 매개변수는 전달받은 인자의 값을 복사해서 초기화됩니다. 이를 값에 의한 전달(Pass by Value)이라고 합니다.
#include <iostream>
#define LOG(x) std::cout << x << '\n'
void Increment(int value)
{
value++;
}
int main()
{
int number = 5;
Increment(number);
LOG(number);
std::cin.get();
return 0;
}
Increment를 호출하면 number의 값인 5가 매개변수 value에 복사됩니다.
함수 안에서 증가하는 대상은 복사본인 value이므로 호출한 쪽의 number는 변경되지 않습니다.
출력 결과
5
함수가 종료되면 매개변수 value도 사라지지만, 원래 변수 number는 그대로 유지됩니다.
레퍼런스에 의한 전달
함수의 매개변수를 레퍼런스로 선언하면 호출할 때 전달한 원래 변수를 참조할 수 있습니다.
void Increment(int& value)
{
value++;
}
value는 전달받은 값을 저장하는 복사본이 아니라 호출한 쪽에 있는 변수의 별칭이 됩니다.
#include <iostream>
#define LOG(x) std::cout << x << '\n'
void Increment(int& value)
{
value++;
}
int main()
{
int number = 5;
Increment(number);
LOG(number);
std::cin.get();
return 0;
}
Increment(number)를 호출하면 매개변수 value는 number를 참조합니다.
따라서 함수 안에서 실행한 value++는 원래 변수 number의 값을 증가시킵니다.
출력 결과
6
이처럼 함수가 레퍼런스 매개변수를 통해 호출한 쪽의 원래 객체에 접근하는 방식을 레퍼런스에 의한 전달(Pass by Reference)이라고 합니다.
| 전달 방식 | 매개변수 | 원래 변수 변경 여부 |
|---|---|---|
| 값에 의한 전달 | void Increment(int value) |
변경되지 않음 |
| 레퍼런스에 의한 전달 | void Increment(int& value) |
변경됨 |
레퍼런스 매개변수 사용 시 주의점
레퍼런스 매개변수는 함수 호출 문법만 보았을 때 값 전달과 구분하기 어려울 수 있습니다.
Increment(number);
함수 선언을 확인하지 않으면 number가 복사되는지, 함수 안에서 변경될 수 있는지 바로 알기 어렵습니다.
void Increment(int& value);
따라서 변경 가능한 레퍼런스 매개변수를 사용하는 함수는 이름과 역할을 명확하게 작성하는 것이 좋습니다.
함수가 원래 객체를 변경하지 않고 읽기만 해야 한다면 const 레퍼런스를 사용할 수 있습니다.
void PrintValue(const int& value)
{
std::cout << value << '\n';
}
const int&를 사용하면 객체를 복사하지 않고 전달하면서 함수 안에서 값을 변경하지 못하도록 제한할 수 있습니다.
다만 int처럼 크기가 작은 기본 타입은 일반적으로 값으로 전달하는 편이 더 단순합니다. const 레퍼런스는 크기가 큰 클래스나 구조체를 복사하지 않고 전달할 때 주로 활용됩니다.
레퍼런스는 언제 사용할까?
레퍼런스는 참조 대상이 반드시 존재하며, 다른 대상으로 변경할 필요가 없을 때 사용할 수 있습니다.
함수의 매개변수에서 레퍼런스를 사용하는 대표적인 목적은 다음과 같습니다.
- 호출한 쪽의 원래 변수를 함수 안에서 변경합니다.
- 크기가 큰 객체를 복사하지 않고 전달합니다.
- 포인터와 역참조 문법 없이 원래 객체에 간결하게 접근합니다.
- 참조 대상이 반드시 존재한다는 의도를 표현합니다.
함수에서 원래 값을 변경하려면 일반 레퍼런스를 사용할 수 있습니다.
void Reset(int& value)
{
value = 0;
}
복사 비용을 줄이면서 읽기만 하려면 const 레퍼런스를 사용할 수 있습니다.
void PrintCharacter(const Character& character)
{
character.Print();
}
반면 대상이 존재하지 않을 수 있음을 표현해야 한다면 포인터를 고려할 수 있습니다.
void SetTarget(Character* target)
{
if (target)
{
// target 사용
}
}
포인터와 레퍼런스 중 무엇을 사용할지는 단순히 문법이 짧은지를 기준으로 결정하기보다, 대상의 존재 여부와 변경 가능성, 소유 관계를 고려해서 결정해야 합니다.
정리
- 레퍼런스는 기존 변수 또는 객체의 별칭입니다.
- L-value 레퍼런스는 타입 뒤에
&를 작성해 선언합니다. - 레퍼런스를 통해 값을 읽거나 변경하면 원래 변수의 값을 읽거나 변경하게 됩니다.
- 레퍼런스는 선언과 동시에 참조할 대상을 지정해야 합니다.
- 일반적인 레퍼런스에는 포인터의
nullptr와 같은 비어 있는 상태가 없습니다. - 일반적인 변경 가능한 L-value 레퍼런스는 임시 값을 직접 참조할 수 없습니다.
- 한 번 초기화된 레퍼런스는 다른 변수를 참조하도록 변경할 수 없습니다.
- 레퍼런스에 다른 변수를 대입하면 참조 대상이 바뀌는 것이 아니라 해당 변수의 값이 원래 참조 대상에 대입됩니다.
- 레퍼런스에 주소 연산자를 적용하면 원래 객체의 주소를 얻습니다.
- 포인터는 역참조 연산자가 필요하지만 레퍼런스는 일반 변수와 같은 문법으로 사용할 수 있습니다.
- 포인터는 다른 주소를 저장할 수 있지만 레퍼런스는 참조 대상을 변경할 수 없습니다.
- 값에 의한 전달에서는 인자의 값이 매개변수에 복사됩니다.
- 레퍼런스에 의한 전달에서는 매개변수가 호출한 쪽의 원래 변수를 참조합니다.
- 변경 가능한 레퍼런스 매개변수를 사용하면 함수 안에서 원래 변수의 값을 변경할 수 있습니다.
const레퍼런스를 사용하면 객체를 복사하지 않고 읽기 전용으로 전달할 수 있습니다.- 참조 대상이 반드시 존재하고 다른 대상으로 변경할 필요가 없다면 레퍼런스를 사용할 수 있습니다.
- 참조 대상이 존재하지 않을 수 있거나 다른 대상을 가리켜야 한다면 포인터가 더 적합할 수 있습니다.
이전 글: C++ 포인터란? 주소와 역참조부터 동적 메모리 할당까지
다음 글: C++ 함수에서 포인터와 레퍼런스 사용하기