함수의 매개변수를 일반 변수로 선언하면 호출할 때 전달한 값이 매개변수에 복사됩니다.
void ChangeValue(int value)
{
value = 20;
}
함수 안에서 복사본인 value를 변경해도 호출한 쪽의 원본 변수에는 영향을 주지 않습니다.
함수에서 원본 변수의 값을 변경하려면 포인터나 레퍼런스를 사용할 수 있습니다.
void ChangeWithPointer(int* value)
{
*value = 20;
}
void ChangeWithReference(int& value)
{
value = 20;
}
포인터를 사용하면 원본 변수의 주소를 함수에 전달할 수 있습니다. 레퍼런스를 사용하면 주소 연산자와 역참조 연산자 없이 더 간결한 문법으로 원본 변수에 접근할 수 있습니다.
이번 글에서는 포인터와 레퍼런스를 함수의 매개변수로 사용하는 방법을 비교하고, 함수에서 레퍼런스를 반환할 때 주의해야 할 객체의 수명 문제까지 알아보겠습니다.
목차
- 값에 의한 전달
- 포인터를 사용해 원본 변수 변경하기
- 포인터를 사용한 Swap 함수
- 포인터 전달도 Call by Reference일까?
- 포인터 매개변수를 사용할 때의 주의점
- 포인터 자체를 const로 제한하기
- 레퍼런스를 사용해 원본 변수 변경하기
- 레퍼런스를 사용한 Swap 함수
- 포인터와 레퍼런스 매개변수 비교하기
- 레퍼런스를 반환하는 함수
- 반환된 레퍼런스로 원본 변경하기
- 지역 변수의 레퍼런스를 반환하면 안 되는 이유
- 언제 레퍼런스를 반환할 수 있을까?
- 정리
값에 의한 전달
일반적인 함수 매개변수는 호출할 때 전달한 인자의 값으로 초기화됩니다. 이를 값에 의한 전달(Pass by Value)이라고 합니다.
#include <iostream>
void ChangeValue(int value)
{
value = 20;
}
int main()
{
int number = 10;
ChangeValue(number);
std::cout << number << '\n';
}
ChangeValue(number)를 호출하면 number의 값인 10이 매개변수 value에 복사됩니다.
함수 안에서 변경한 대상은 원본 변수인 number가 아니라 복사본인 value입니다. 따라서 함수가 종료된 뒤에도 number의 값은 바뀌지 않습니다.
실행 결과
10
함수에서 원본 변수를 변경하려면 원본 객체에 접근할 수 있는 포인터나 레퍼런스를 매개변수로 사용해야 합니다.
포인터를 사용해 원본 변수 변경하기
함수의 매개변수를 포인터로 선언하면 원본 변수의 주소를 전달받을 수 있습니다.
void ChangeValue(int* value)
{
*value = 20;
}
함수를 호출할 때는 주소 연산자 &를 사용해 원본 변수의 주소를 전달합니다.
int number = 10; ChangeValue(&number);
함수의 매개변수 value에는 number의 메모리 주소가 저장됩니다. 함수 안에서 *value를 사용하면 이 주소를 역참조하여 원본 변수에 접근할 수 있습니다.
#include <iostream>
void ChangeValue(int* value)
{
*value = 20;
}
int main()
{
int number = 10;
ChangeValue(&number);
std::cout << number << '\n';
}
실행 결과
20
함수에서 복사된 포인터 값을 통해 호출한 쪽의 원본 변수에 접근했으므로 number의 값이 20으로 변경됩니다.
포인터를 사용한 Swap 함수
포인터를 사용해 두 원본 변수의 값을 서로 바꾸는 Swap 함수를 작성해보겠습니다.
#include <iostream>
void Swap(int* numberA, int* numberB)
{
int temp = *numberA;
*numberA = *numberB;
*numberB = temp;
}
int main()
{
int number1 = 10;
int number2 = 20;
std::cout << "number1: " << number1 << ' '
<< "number2: " << number2 << '\n';
Swap(&number1, &number2);
std::cout << "number1: " << number1 << ' '
<< "number2: " << number2 << '\n';
}
Swap 함수를 호출할 때 number1과 number2의 주소를 전달합니다.
Swap(&number1, &number2);
함수의 매개변수에는 다음과 같은 주소가 저장됩니다.
numberA에는number1의 주소가 저장됩니다.numberB에는number2의 주소가 저장됩니다.
함수 안에서는 두 포인터를 역참조해 원본 변수의 값을 서로 교환합니다.
int temp = *numberA; *numberA = *numberB; *numberB = temp;
실행 결과
number1: 10 number2: 20 number1: 20 number2: 10
포인터 전달도 Call by Reference일까?
포인터를 이용해 함수에서 원본 변수를 변경하는 방식을 일반적으로 Call by Reference 또는 참조에 의한 호출이라고 표현하기도 합니다.
하지만 C++의 인자 전달 방식을 엄밀하게 구분하면 포인터 매개변수에는 주소 값이 복사되어 전달됩니다.
void ChangeValue(int* value); int number = 10; ChangeValue(&number);
&number로 얻은 주소 값이 포인터 매개변수 value에 복사됩니다. 따라서 포인터 변수 자체는 값으로 전달됩니다.
다만 복사된 포인터가 원본 변수의 주소를 가지고 있으므로, 포인터를 역참조하면 원본 변수에 접근할 수 있습니다.
포인터를 이용한 방식은 원본 변수에 간접적으로 접근한다는 의미에서 Call by Reference라고 부르기도 하지만, 언어의 매개변수 전달 규칙으로 보면 주소를 담은 포인터를 값으로 전달하는 방식입니다.
| 방식 | 함수에 전달되는 것 | 원본 접근 방법 |
|---|---|---|
| 값에 의한 전달 | 원본 변수의 값 복사본 | 원본에 접근할 수 없음 |
| 포인터에 의한 전달 | 원본 변수의 주소 값 복사본 | 포인터를 역참조해 접근 |
| 레퍼런스에 의한 전달 | 원본 객체에 연결되는 레퍼런스 | 일반 변수와 같은 문법으로 접근 |
포인터 매개변수를 사용할 때의 주의점
포인터 변수에는 다른 주소를 대입하거나 포인터 연산을 수행할 수 있습니다.
void Swap(int* numberA, int* numberB)
{
int temp = *numberA;
numberA++;
*numberA = *numberB;
*numberB = temp;
}
numberA++가 실행되면 numberA는 처음 전달받은 정수의 주소가 아니라 다음 int가 위치할 것으로 계산된 주소를 가리키게 됩니다.
그러나 전달받은 변수가 배열의 요소라는 보장은 없습니다. 유효하지 않은 위치를 역참조하면 정의되지 않은 동작이 발생할 수 있습니다.
포인터 매개변수는 nullptr를 전달받을 수도 있습니다.
Swap(nullptr, nullptr);
이 상태에서 *numberA 또는 *numberB를 실행하면 널 포인터를 역참조하게 됩니다.
포인터 매개변수가 nullptr일 수 있도록 설계된 함수라면 사용 전에 검사해야 합니다.
void Swap(int* numberA, int* numberB)
{
if (numberA == nullptr || numberB == nullptr)
{
return;
}
int temp = *numberA;
*numberA = *numberB;
*numberB = temp;
}
반대로 매개변수가 반드시 유효한 객체를 나타내야 한다면 레퍼런스를 사용해 그 의도를 표현할 수 있습니다.
포인터 자체를 const로 제한하기
함수 안에서 포인터가 다른 주소를 가리키도록 변경할 필요가 없다면 포인터 자체에 const를 적용할 수 있습니다.
void Swap(int* const numberA, int* const numberB)
{
int temp = *numberA;
*numberA = *numberB;
*numberB = temp;
}
int* const는 상수 포인터를 의미합니다.
- 포인터에 저장된 주소는 변경할 수 없습니다.
- 포인터가 가리키는
int값은 변경할 수 있습니다.
void ChangeValue(int* const value)
{
*value = 20; // 가능
// value++; // 오류
// value = nullptr; // 오류
}
const가 포인터 자체에 적용되었기 때문에 value에 다른 주소를 대입하거나 포인터 연산으로 저장된 주소를 변경할 수 없습니다.
다만 함수의 포인터 매개변수는 원래 호출할 때 전달된 포인터의 복사본입니다. 따라서 포인터 매개변수를 다른 주소로 변경하더라도 호출한 쪽의 포인터 자체가 바뀌는 것은 아닙니다.
void ResetPointer(int* pointer)
{
pointer = nullptr;
}
int number = 10;
int* pointer = &number;
ResetPointer(pointer);
// 호출한 쪽의 pointer는 여전히 number를 가리킨다.
int* const는 포인터에 저장된 주소를 변경하지 못하게 합니다. 포인터가 가리키는 값을 변경하지 못하게 하려면const int*를 사용해야 합니다.
| 선언 | 주소 변경 | 가리키는 값 변경 |
|---|---|---|
int* pointer |
가능 | 가능 |
int* const pointer |
불가능 | 가능 |
const int* pointer |
가능 | 불가능 |
const int* const pointer |
불가능 | 불가능 |
레퍼런스를 사용해 원본 변수 변경하기
함수의 매개변수를 레퍼런스로 선언하면 호출할 때 전달한 원본 변수의 별칭으로 사용할 수 있습니다.
void ChangeValue(int& value)
{
value = 20;
}
함수를 호출할 때는 포인터 방식과 달리 주소 연산자를 사용하지 않습니다.
int number = 10; ChangeValue(number);
함수의 매개변수 value는 number의 별칭이 됩니다. 따라서 함수 안에서 value를 변경하면 원본 변수 number의 값이 변경됩니다.
#include <iostream>
void ChangeValue(int& value)
{
value = 20;
}
int main()
{
int number = 10;
ChangeValue(number);
std::cout << number << '\n';
}
실행 결과
20
레퍼런스를 사용할 때는 주소 연산자와 역참조 연산자가 필요하지 않습니다. 함수 안에서도 일반 변수와 같은 문법으로 원본 변수에 접근합니다.
레퍼런스를 사용한 Swap 함수
앞서 포인터로 작성했던 Swap 함수를 레퍼런스 매개변수로 변경해보겠습니다.
#include <iostream>
void Swap(int& numberA, int& numberB)
{
int temp = numberA;
numberA = numberB;
numberB = temp;
}
int main()
{
int number1 = 10;
int number2 = 20;
std::cout << "number1: " << number1 << ' '
<< "number2: " << number2 << '\n';
Swap(number1, number2);
std::cout << "number1: " << number1 << ' '
<< "number2: " << number2 << '\n';
}
Swap의 매개변수는 다음과 같은 별칭으로 동작합니다.
numberA는number1의 별칭이 됩니다.numberB는number2의 별칭이 됩니다.
따라서 함수 안에서 numberA와 numberB를 변경하면 호출한 쪽의 원본 변수 값이 변경됩니다.
실행 결과
number1: 10 number2: 20 number1: 20 number2: 10
포인터를 사용한 코드와 비교하면 주소 연산자와 역참조 연산자가 사라져 코드가 간결해집니다.
| 구분 | 포인터 | 레퍼런스 |
|---|---|---|
| 함수 선언 | void Swap(int*, int*) |
void Swap(int&, int&) |
| 함수 호출 | Swap(&number1, &number2) |
Swap(number1, number2) |
| 원본 값 접근 | *numberA |
numberA |
포인터와 레퍼런스 매개변수 비교하기
포인터와 레퍼런스 모두 함수에서 호출한 쪽의 원본 객체에 접근할 수 있지만, 표현할 수 있는 상태와 사용 문법이 다릅니다.
| 구분 | 포인터 매개변수 | 레퍼런스 매개변수 |
|---|---|---|
| 선언 | int* value |
int& value |
| 함수 호출 | 주소 또는 포인터 전달 | 원본 변수 전달 |
| 원본 접근 | 역참조 필요 | 일반 변수와 같은 문법 |
| 대상 변경 | 다른 주소를 저장할 수 있음 | 참조 대상을 변경할 수 없음 |
| 대상이 없는 상태 | nullptr로 표현 가능 |
일반적으로 허용하지 않음 |
| 사용 전 검사 | nullptr 검사 필요 가능 |
정상적으로 전달된 객체가 있다고 가정 |
함수의 대상이 존재하지 않을 수 있다면 포인터를 사용할 수 있습니다.
void PrintValue(const int* value)
{
if (value)
{
std::cout << *value << '\n';
}
}
반드시 존재하는 원본 객체를 전달받아야 한다면 레퍼런스를 사용해 의도를 나타낼 수 있습니다.
void Increment(int& value)
{
++value;
}
레퍼런스를 사용한다고 해서 참조 대상의 수명 문제가 사라지는 것은 아닙니다. 함수가 레퍼런스를 저장하거나 반환한다면 원본 객체가 언제까지 유효한지 확인해야 합니다.
레퍼런스를 반환하는 함수
함수는 값뿐만 아니라 레퍼런스도 반환할 수 있습니다.
int& IncreaseNumber(int& value)
{
++value;
return value;
}
반환형에 작성한 int&는 함수가 int 값을 복사해 반환하는 대신 기존 int 객체의 레퍼런스를 반환한다는 의미입니다.
#include <iostream>
int& IncreaseNumber(int& value)
{
++value;
return value;
}
int main()
{
int number = 10;
int& refNumber = IncreaseNumber(number);
std::cout << "number: " << number << '\n';
std::cout << "refNumber: " << refNumber << '\n';
}
IncreaseNumber(number)를 호출하면 매개변수 value는 number의 별칭이 됩니다.
함수 안에서 value를 증가시키면 원본 변수 number의 값도 증가합니다.
++value;
함수는 value가 참조하던 원본 객체의 레퍼런스를 반환합니다.
return value;
함수가 종료되면 매개변수 이름 value의 사용 범위는 끝나지만, value가 참조하던 원본 변수 number는 main 함수 안에 계속 존재합니다.
반환된 레퍼런스로 refNumber를 초기화했으므로 number와 refNumber는 같은 변수를 나타냅니다.
실행 결과
number: 11 refNumber: 11
반환된 레퍼런스로 원본 변경하기
레퍼런스를 반환하는 함수의 호출 결과는 원본 객체를 나타냅니다. 따라서 반환된 레퍼런스를 통해 원본 값을 변경할 수 있습니다.
int& GetNumber(int& value)
{
return value;
}
int main()
{
int number = 10;
GetNumber(number) = 30;
std::cout << number << '\n';
}
GetNumber(number)는 number의 레퍼런스를 반환합니다.
GetNumber(number) = 30;
따라서 이 대입은 원본 변수 number에 30을 저장합니다.
실행 결과
30
배열이나 컨테이너의 특정 요소를 레퍼런스로 반환하는 함수에서도 이와 같은 방식을 사용할 수 있습니다.
int& GetElement(int values[], int index)
{
return values[index];
}
int main()
{
int values[3] = { 10, 20, 30 };
GetElement(values, 1) = 50;
std::cout << values[1] << '\n';
}
함수가 반환한 레퍼런스는 배열의 두 번째 요소를 나타내므로 values[1]의 값이 50으로 변경됩니다.
레퍼런스를 반환하면 호출자가 원본 객체를 직접 변경할 수 있습니다. 읽기만 허용해야 한다면 반환형을
const int&와 같은 상수 레퍼런스로 선언하는 방법을 고려할 수 있습니다.
지역 변수의 레퍼런스를 반환하면 안 되는 이유
레퍼런스를 반환할 때는 참조 대상이 함수 종료 후에도 유효한지 반드시 확인해야 합니다.
다음 함수는 지역 변수의 레퍼런스를 반환하므로 잘못된 코드입니다.
int& CreateNumber()
{
int number = 10;
return number;
}
number는 CreateNumber 함수의 지역 변수입니다. 지역 변수의 수명은 함수가 실행되는 동안에만 유지되며, 함수가 종료되면 끝납니다.
함수에서 number의 레퍼런스를 반환하더라도 참조 대상인 number는 함수 종료와 함께 더 이상 존재하지 않습니다.
int& reference = CreateNumber(); // reference가 참조하는 지역 변수의 수명은 이미 끝났다. std::cout << reference << '\n';
이처럼 수명이 끝난 객체를 계속 참조하는 레퍼런스를 댕글링 레퍼런스(Dangling Reference)라고 합니다.
댕글링 레퍼런스를 사용하면 정의되지 않은 동작이 발생합니다. 우연히 예상한 값이 출력될 수도 있지만, 해당 결과를 신뢰할 수 없습니다.
컴파일러가 지역 변수의 레퍼런스를 반환하는 코드에 경고를 표시할 수 있지만, 모든 객체 수명 문제를 컴파일러가 찾아주는 것은 아닙니다. 레퍼런스를 반환할 때는 개발자가 참조 대상의 수명을 직접 확인해야 합니다.
언제 레퍼런스를 반환할 수 있을까?
레퍼런스를 반환하려면 참조 대상이 반환된 레퍼런스를 사용하는 동안 계속 존재해야 합니다.
호출자가 전달한 객체의 레퍼런스를 반환하는 경우
int& IncreaseNumber(int& value)
{
++value;
return value;
}
참조 대상은 호출한 쪽에서 전달한 객체입니다. 호출 후에도 해당 객체가 존재한다면 반환된 레퍼런스를 사용할 수 있습니다.
객체의 멤버를 반환하는 경우
class Character
{
public:
int& GetHealth()
{
return health;
}
private:
int health = 100;
};
GetHealth는 Character 객체에 포함된 멤버 변수의 레퍼런스를 반환합니다.
Character character; character.GetHealth() = 50;
반환된 레퍼런스는 character 객체가 유효한 동안 사용할 수 있습니다.
그러나 character의 수명이 끝난 뒤에는 멤버를 참조하던 레퍼런스도 사용할 수 없습니다.
배열이나 컨테이너의 요소를 반환하는 경우
int& GetElement(int values[], int index)
{
return values[index];
}
이 경우에도 원본 배열과 해당 요소가 반환된 레퍼런스를 사용하는 동안 유효해야 합니다.
표준 컨테이너의 요소를 참조하는 경우에는 컨테이너 변경으로 요소의 주소가 무효화될 수도 있으므로 추가로 주의해야 합니다.
정적 지역 변수를 반환하는 경우
int& GetCount()
{
static int count = 0;
return count;
}
정적 지역 변수는 함수가 종료되어도 수명이 끝나지 않으므로 레퍼런스로 반환할 수 있습니다.
다만 전역 상태와 비슷하게 여러 코드에서 같은 값을 공유하게 되므로 설계 목적을 고려해 사용해야 합니다.
| 참조 대상 | 레퍼런스 반환 | 조건 |
|---|---|---|
| 함수의 일반 지역 변수 | 불가능 | 함수 종료 시 수명이 끝남 |
| 호출자가 전달한 객체 | 가능 | 호출 후에도 원본 객체가 유효해야 함 |
| 객체의 멤버 | 가능 | 멤버를 소유한 객체가 유효해야 함 |
| 배열 또는 컨테이너의 요소 | 가능 | 요소가 존재하며 무효화되지 않아야 함 |
| 정적 지역 변수 | 가능 | 공유 상태 사용이 설계에 적합해야 함 |
정리
- 일반 매개변수에는 호출할 때 전달한 값이 복사됩니다.
- 포인터와 레퍼런스를 사용하면 함수에서 호출한 쪽의 원본 변수에 접근할 수 있습니다.
- 포인터 매개변수에는 원본 변수의 주소 값이 복사되어 전달됩니다.
- 포인터를 사용해 원본에 접근하려면 호출할 때 주소를 전달하고 함수 안에서 포인터를 역참조해야 합니다.
- 포인터를 이용한 전달도 Call by Reference라고 부르지만, 엄밀히는 주소를 저장한 포인터를 값으로 전달합니다.
- 포인터 매개변수는 다른 주소를 저장하거나 포인터 연산을 수행할 수 있습니다.
- 포인터가 가리키는 대상을 변경할 필요가 없다면 포인터 자체를
const로 선언할 수 있습니다. int* const는 주소를 변경할 수 없지만 가리키는int값은 변경할 수 있습니다.- 포인터 매개변수에는
nullptr가 전달될 수 있으므로 함수의 설계에 따라 검사가 필요합니다. - 레퍼런스 매개변수는 호출한 쪽에서 전달한 원본 변수의 별칭이 됩니다.
- 레퍼런스를 사용하면 주소 연산자와 역참조 연산자 없이 원본 변수에 접근할 수 있습니다.
- 레퍼런스는 참조 대상을 변경할 수 없지만 참조하는 변수의 값은 변경할 수 있습니다.
- 함수는 기존 객체의 레퍼런스를 반환할 수 있습니다.
- 반환된 레퍼런스를 사용하면 원본 객체의 값을 읽거나 변경할 수 있습니다.
- 레퍼런스를 반환할 때는 참조 대상이 함수 종료 후에도 유효해야 합니다.
- 일반 지역 변수는 함수가 종료되면 수명이 끝나므로 레퍼런스로 반환하면 안 됩니다.
- 수명이 끝난 객체를 참조하는 댕글링 레퍼런스를 사용하면 정의되지 않은 동작이 발생합니다.
- 호출자가 전달한 객체, 객체의 멤버, 유효한 배열 요소 또는 정적 지역 변수 등은 수명 조건을 만족할 때 레퍼런스로 반환할 수 있습니다.