C++ 참조: lvalue·rvalue·const 참조, 참조 vs 포인터, 참조 축약 규칙
들어가며: 함수에 큰 객체를 넘기면 복사가 너무 많다
C++에서 참조(reference)는 이미 존재하는 객체에 붙이는 또 하나의 이름, 즉 별칭(alias)입니다. 포인터처럼 다른 객체를 가리키지만, null이 될 수 없고 선언할 때 반드시 초기화해야 하며, 한 번 묶인 대상을 바꿀 수 없습니다. 역참조 연산자 없이 원래 변수처럼 쓸 수 있다는 점도 다릅니다.
참조가 가장 먼저 필요해지는 곳은 함수 인자입니다. processData(std::vector<int> data)처럼 값으로 받으면 호출할 때마다 벡터 전체가 복사됩니다. 원소가 100만 개인 int 벡터라면 4MB를 할당하고 복사하는 셈입니다. 참조로 받으면 복사 없이 호출자의 원본을 그대로 씁니다.
flowchart LR
subgraph copy["값 전달 (복사)"]
C1[호출자 벡터] -->|복사| C2[함수 내 data]
end
subgraph ref[참조 전달]
R1[호출자 벡터] -.->|참조| R2[함수 내 data]
end
// 값 전달: 호출마다 복사 발생
void processData(std::vector<int> data);
// const 참조: 복사 없음, 읽기만
void processData(const std::vector<int>& data);
읽기만 하는 함수 인자는 const T&부터 떠올리고, 지역 변수에 대한 참조를 반환하지 않는다는 두 가지만 지켜도 참조 관련 실수의 대부분을 피할 수 있습니다. 이 글은 lvalue 참조, rvalue 참조, const 참조, 포인터와의 선택 기준, 참조 축약 규칙을 차례로 다룹니다. 함께 보면 좋은 글로 auto와 decltype, 범위 기반 for가 있습니다.
lvalue 참조: 별칭으로 인자를 받고 반환하기
기본 문법
lvalue 참조(T&)는 이름이 있고 주소를 취할 수 있는 식(lvalue)에 묶입니다.
// g++ -std=c++17 -o ref_basic ref_basic.cpp && ./ref_basic
#include <iostream>
int main() {
int x = 10;
int& ref = x; // ref는 x의 별칭
ref = 20; // x를 20으로 변경
std::cout << "x = " << x << "\n"; // 20
// int& ref2 = 42; // 에러: 비const lvalue 참조는 rvalue에 묶일 수 없음
return 0;
}
ref와 x는 같은 객체이므로 ref를 바꾸면 x가 바뀝니다. 42 같은 리터럴은 rvalue라서 const가 아닌 lvalue 참조에는 묶을 수 없습니다. 만약 묶을 수 있다면 ref2 = 7;이 곧 사라질 임시 값을 수정하는 무의미한 코드가 되기 때문입니다.
| 특성 | 설명 |
|---|---|
| null 불가 | 참조는 반드시 유효한 객체를 가리켜야 함 |
| 재바인딩 불가 | 한 번 묶이면 다른 객체로 바꿀 수 없음 (ref = y;는 x에 y의 값을 대입) |
| 별칭 | 참조와 원본은 같은 객체 |
| 초기화 필수 | 선언과 동시에 초기화해야 함 |
함수 인자로 lvalue 참조
#include <iostream>
void increment(int& x) {
++x;
}
void swap(int& a, int& b) {
int temp = a;
a = b;
b = temp;
}
int main() {
int a = 10, b = 20;
increment(a);
std::cout << "a = " << a << "\n"; // 11
swap(a, b);
std::cout << "a = " << a << ", b = " << b << "\n"; // 20, 11
}
increment의 x는 호출자의 a 자체이므로 ++x가 a를 증가시킵니다. 같은 일을 포인터로 하려면 swap(int* a, int* b)로 받아 *a, *b로 역참조하고 호출할 때 &a를 넘겨야 하지만, 참조는 그런 기호 없이 같은 효과를 냅니다. 대신 호출부 increment(a)만 봐서는 a가 바뀐다는 사실이 드러나지 않으므로, 함수 이름으로 수정 의도를 분명히 하는 것이 좋습니다.
lvalue 참조 반환
#include <iostream>
#include <vector>
// 안전: 호출자가 넘긴 객체의 참조를 그대로 반환
std::vector<int>& getRef(std::vector<int>& vec) {
return vec;
}
// 위험: 지역 변수의 참조 반환 (dangling reference)
// std::vector<int>& getBadRef() {
// std::vector<int> local = {1, 2, 3};
// return local; // 함수 종료 후 local 파괴 → 미정의 동작
// }
int main() {
std::vector<int> data = {1, 2, 3};
std::vector<int>& ref = getRef(data);
ref.push_back(4);
std::cout << "data.size() = " << data.size() << "\n"; // 4
}
참조 반환은 반환하는 객체가 호출자보다 오래 살아 있을 때만 안전합니다. 인자로 받은 객체, *this의 멤버, static 객체가 여기에 해당합니다.
범위 기반 for에서 참조
#include <iostream>
#include <vector>
int main() {
std::vector<int> vec = {1, 2, 3, 4, 5};
for (int x : vec) {
x *= 2; // 복사본만 바뀌고 vec 원소는 그대로
}
for (int& x : vec) {
x *= 2; // 원소 수정: vec = {2, 4, 6, 8, 10}
}
for (const int& x : vec) {
std::cout << x << " "; // 읽기 전용
}
}
int처럼 작은 타입은 읽기만 할 때 int x로 받아도 비용 차이가 없습니다. std::string이나 구조체처럼 복사 비용이 있는 원소를 읽을 때는 const auto&가 기본 선택입니다.
rvalue 참조와 이동 오버로딩
그림: 실제 실행 결과 — 이름 있는 변수는 lvalue 오버로드(읽기만)로, 임시 값은 rvalue 오버로드(이동 가능)로 호출된다.
기본 문법
rvalue 참조(T&&)는 C++11에서 추가되었고, 임시 객체나 std::move로 넘긴 값처럼 “곧 쓰이지 않을 값”(rvalue)에 묶입니다. 이동 의미론의 기반입니다.
#include <iostream>
int main() {
int x = 10;
int& lref = x; // lvalue 참조: lvalue에 묶임
int&& rref = 42; // rvalue 참조: rvalue에 묶임
// int&& rref2 = x; // 에러: lvalue를 rvalue 참조에 묶을 수 없음
std::cout << "lref = " << lref << ", rref = " << rref << "\n";
}
rvalue 참조로 오버로딩해 이동하기
#include <iostream>
#include <string>
#include <utility>
void process(std::string&& str) {
std::cout << "rvalue: " << str << " (이동 가능)\n";
std::string moved = std::move(str); // str은 이름이 있으므로 std::move가 필요
}
void process(const std::string& str) {
std::cout << "lvalue: " << str << " (읽기만)\n";
}
int main() {
std::string a = "Hello";
process(a); // lvalue → const std::string& 버전
process(std::string("World")); // 임시 객체 → std::string&& 버전
process("Hi"); // const char[3]에서 만든 임시 std::string → && 버전
}
process(a)의 a는 lvalue라서 const std::string& 버전이 선택됩니다. 임시 객체는 두 버전 모두에 묶일 수 있지만, rvalue는 rvalue 참조에 묶는 쪽이 더 나은 변환으로 취급되어 && 버전이 선택됩니다.
주의할 점은 process(std::string&& str) 안의 str입니다. 타입은 rvalue 참조지만 이름이 있으므로 식으로서는 lvalue입니다. 그래서 std::string moved = str;이라고 쓰면 복사가 일어나고, 이동하려면 std::move(str)로 다시 rvalue로 캐스팅해야 합니다.
같은 원리로 멤버 setter를 복사용과 이동용으로 나눌 수 있습니다.
#include <iostream>
#include <string>
#include <utility>
class Widget {
std::string name_;
public:
void setName(const std::string& name) {
name_ = name; // 복사
std::cout << "setName (복사)\n";
}
void setName(std::string&& name) {
name_ = std::move(name); // 이동
std::cout << "setName (이동)\n";
}
};
int main() {
Widget w;
std::string s = "Alice";
w.setName(s); // 복사
w.setName(std::string("Bob")); // 이동
w.setName("Charlie"); // 임시 std::string이 만들어져 이동
}
매개변수가 여러 개가 되면 오버로드 조합이 급격히 늘어납니다. 그럴 때는 void setName(std::string name) { name_ = std::move(name); }처럼 값으로 받아 이동하는 방식이 간단하고, lvalue에는 복사 한 번과 이동 한 번, rvalue에는 이동 두 번으로 비용 차이도 크지 않습니다.
const 참조와 임시 객체 수명 연장
const lvalue 참조의 특성
const T&는 읽기 전용 참조이며 lvalue와 rvalue를 모두 받을 수 있습니다. 그래서 “복사 없이 받되 수정하지 않는” 함수 인자의 기본형으로 널리 쓰입니다.
#include <iostream>
#include <string>
void print(const std::string& s) {
std::cout << s << "\n";
// s[0] = 'X'; // 에러: const이므로 수정 불가
}
int main() {
std::string text = "Hello";
print(text); // lvalue
print("World"); // const char*에서 임시 std::string이 만들어져 바인딩
print(std::string("Hi")); // rvalue
}
print("World")처럼 문자열 리터럴을 넘기면 임시 std::string이 만들어지면서 할당이 일어날 수 있습니다. 읽기만 하는 문자열 인자라면 C++17의 std::string_view로 받아 이 임시 객체 생성도 피할 수 있습니다.
const 참조와 수명 연장
임시 객체를 const T&(또는 T&&) 지역 변수에 직접 묶으면, 그 임시 객체의 수명이 참조의 수명만큼 연장됩니다.
#include <iostream>
#include <string>
std::string makeName() {
return "temporary";
}
int main() {
const std::string& ref = makeName(); // 반환된 임시 객체의 수명이 ref만큼 연장
std::cout << ref << "\n"; // 안전
}
이 규칙은 “임시 객체를 지역 참조에 직접 바인딩할 때”에만 적용됩니다. 함수가 참조를 반환하는 경우에는 연장이 일어나지 않습니다.
const int& getRef() {
return 42; // 임시 int를 만들어 참조로 반환 → 함수가 끝나면 파괴
}
int main() {
const int& ref = getRef(); // dangling reference: 사용하면 미정의 동작
}
getRef 안에서 만들어진 임시 int는 return 문이 끝나면서 사라지므로, 호출자가 받는 참조는 이미 파괴된 객체를 가리킵니다. GCC와 Clang은 이런 코드에 “returning reference to temporary” 경고를 냅니다.
const 참조 vs 값 전달
// 작은 타입 (int, double, 포인터): 값 전달
void process(int x);
void process(double x);
// 복사 비용이 큰 타입: const 참조
void process(const std::string& s);
void process(const std::vector<int>& v);
void process(const std::map<std::string, int>& m);
| 구분 | 값 전달 | const 참조 |
|---|---|---|
| int, double, 포인터 | 권장 | 이득 없음 (오히려 간접 참조 비용) |
| std::string | 복사 비용 | 권장 (또는 std::string_view) |
| std::vector 등 컨테이너 | 복사 비용 큼 | 권장 |
| 함수 안에서 어차피 사본이 필요 | 값으로 받아 이동 | - |
작은 타입을 const int&로 받으면 레지스터로 값을 넘기는 대신 주소를 넘기고 메모리에서 다시 읽어야 하므로, 이득이 없거나 오히려 손해입니다.
참조와 포인터 중 무엇을 쓸까
| 항목 | 참조 (T&) | 포인터 (T*) |
|---|---|---|
| null | 불가 | 가능 |
| 초기화 | 필수 | 선택 (초기화하지 않으면 쓰레기 값) |
| 재바인딩 | 불가 | 가능 |
| 문법 | ref | *ptr, ptr-> |
| 산술 연산 | 불가 | ptr++, ptr + n |
| 주 용도 | 별칭, 인자 전달 | 선택적 인자, 재지정, 배열 순회, 저수준 API |
참조는 “반드시 유효한 객체”를 받는다는 계약을 타입으로 표현합니다. 함수 인자, 이미 존재하는 객체를 돌려주는 반환값, 범위 기반 for에서 기본으로 씁니다.
void process(const std::string& s); // 인자: 반드시 유효한 객체
const std::string& getName() const; // 반환: 멤버의 별칭
for (const auto& item : container) { } // 순회
포인터는 “없을 수도 있다”거나 “가리키는 대상이 바뀐다”는 의미가 필요할 때 씁니다. 동적 할당 객체의 소유는 원시 포인터 대신 std::unique_ptr이나 std::shared_ptr로 하고, 원시 포인터는 소유하지 않는 관찰자 역할로 한정하는 것이 현대 C++의 관례입니다.
#include <iostream>
#include <optional>
#include <string>
// 참조: 반드시 유효한 객체
void process(const std::string& s) {
std::cout << s << "\n";
}
// 포인터: null 가능 (호출자가 소유한 객체를 선택적으로 넘김)
void processOptional(const std::string* s) {
if (s) {
std::cout << *s << "\n";
} else {
std::cout << "(null)\n";
}
}
// std::optional: 값이 있거나 없음 (값 자체를 담음)
void processMaybe(const std::optional<std::string>& opt) {
if (opt) {
std::cout << *opt << "\n";
}
}
int main() {
std::string text = "Hello";
process(text);
processOptional(&text);
processOptional(nullptr);
processMaybe(std::optional<std::string>{"World"});
processMaybe(std::nullopt);
}
const std::string*는 호출자의 기존 객체를 가리키고, std::optional<std::string>은 값 자체를 담습니다. 기존 객체를 복사 없이 선택적으로 넘기려면 포인터가, 함수가 “결과가 없을 수 있는 값”을 반환하려면 std::optional이 맞습니다.
참조 축약 규칙과 전달 참조
참조의 참조
C++에서는 “참조의 참조”를 직접 선언할 수 없습니다. 하지만 템플릿 인자 치환, 타입 별칭(using/typedef), decltype을 거치면 참조에 참조가 붙는 상황이 생기고, 이때 참조 축약(reference collapsing) 규칙이 적용됩니다.
T& & → T&
T& && → T&
T&& & → T&
T&& && → T&&
하나라도 lvalue 참조가 있으면 결과는 lvalue 참조이고, 둘 다 rvalue 참조일 때만 rvalue 참조가 됩니다.
전달 참조(forwarding reference)
함수 템플릿에서 T가 추론되는 T&& 매개변수는 rvalue 참조가 아니라 전달 참조(흔히 유니버설 참조라고도 부름)입니다. lvalue를 넘기면 T가 X&로 추론되고 축약 규칙에 따라 매개변수는 X&가 되며, rvalue를 넘기면 T가 X로 추론되어 매개변수는 X&&가 됩니다.
template <typename T>
void func(T&& arg);
int x = 10;
func(x); // x는 lvalue: T = int&, arg 타입 = int& && → int&
func(10); // 10은 rvalue: T = int, arg 타입 = int&&
이를 직접 확인해 볼 수 있습니다.
#include <iostream>
#include <type_traits>
template <typename T>
void test(T&& arg) {
if constexpr (std::is_lvalue_reference_v<decltype(arg)>) {
std::cout << "lvalue reference\n";
} else {
std::cout << "rvalue reference\n";
}
}
int main() {
int x = 10;
test(x); // lvalue reference
test(10); // rvalue reference
}
T가 추론되지 않는 경우, 예를 들어 클래스 템플릿의 멤버 함수 void push(T&& v)에서 T가 이미 클래스 인자로 정해져 있다면 그 T&&는 그냥 rvalue 참조입니다. auto&&도 전달 참조처럼 동작해서, auto&& x = expr;은 expr이 lvalue면 lvalue 참조, rvalue면 rvalue 참조가 됩니다. 범위 기반 for에서 for (auto&& e : range)가 프록시 객체를 반환하는 std::vector<bool> 같은 범위까지 받을 수 있는 이유가 이것입니다.
완벽한 전달
전달 참조로 받은 인자를 다른 함수로 넘길 때, 원래 lvalue였는지 rvalue였는지를 보존하려면 std::forward<T>를 씁니다.
#include <iostream>
#include <string>
#include <utility>
void process(const std::string& s) {
std::cout << "lvalue: " << s << "\n";
}
void process(std::string&& s) {
std::cout << "rvalue: " << s << "\n";
}
template <typename T>
void relay(T&& arg) {
process(std::forward<T>(arg)); // lvalue면 lvalue로, rvalue면 rvalue로 전달
}
int main() {
std::string s = "Hello";
relay(s); // lvalue: Hello
relay(std::string("World")); // rvalue: World
}
std::forward 없이 process(arg)라고 쓰면 arg는 이름이 있으므로 항상 lvalue 버전이 호출됩니다. std::make_unique나 컨테이너의 emplace_back이 생성자 인자를 받아 넘기는 방식이 바로 이 패턴입니다. 자세한 내용은 Perfect Forwarding 글에서 다룹니다.
참조 전달이 실제로 얼마나 차이 나는지 재 보기
#include <chrono>
#include <iostream>
#include <string>
void byValue(std::string s) { (void)s; }
void byConstRef(const std::string& s) { (void)s; }
int main() {
std::string large(1000000, 'a');
using clock = std::chrono::steady_clock;
auto start = clock::now();
for (int i = 0; i < 1000; ++i) byValue(large);
auto end = clock::now();
std::cout << "값 전달: "
<< std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count()
<< " ms\n";
start = clock::now();
for (int i = 0; i < 1000; ++i) byConstRef(large);
end = clock::now();
std::cout << "const 참조: "
<< std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count()
<< " ms\n";
}
값 전달 쪽은 호출마다 1MB 문자열을 할당하고 복사하므로, 1,000번이면 약 1GB를 복사하는 셈입니다. const 참조 쪽은 주소 하나만 넘깁니다. 다만 최적화 빌드에서는 컴파일러가 아무 일도 하지 않는 함수 본문을 보고 복사 자체를 없앨 수도 있으므로, 이런 측정은 -O0과 -O2 결과를 함께 보거나 함수 안에서 인자를 실제로 사용하게 만든 뒤 해석해야 합니다.
자주 나는 에러
지역 변수의 참조 반환 (dangling reference)
#include <iostream>
#include <string>
// 잘못된 코드
const std::string& getBadRef() {
std::string local = "hello";
return local; // local은 함수 종료 시 파괴 → dangling reference
}
int main() {
const std::string& ref = getBadRef();
std::cout << ref << "\n"; // 미정의 동작: 크래시, 쓰레기 값, 또는 우연히 정상
}
함수가 끝나면 지역 변수가 파괴되므로 반환된 참조는 무효입니다. 미정의 동작이라 “우연히 정상 출력”되는 경우도 많아서, 디버그 빌드에서는 멀쩡하다가 다른 환경에서만 깨지기 쉽습니다. 해결책은 값으로 반환하거나, 호출자보다 오래 사는 객체의 참조만 반환하는 것입니다.
// 값 반환: RVO 또는 이동으로 비용이 작음
std::string getValue() {
std::string local = "hello";
return local;
}
// 함수 안의 static 객체: 프로그램 종료까지 살아 있음
const std::string& getStatic() {
static const std::string s = "hello";
return s;
}
GCC의 -Wreturn-local-addr(기본 활성), Clang의 -Wreturn-stack-address가 이 패턴을 잡아 줍니다. GCC 13 이상의 -Wdangling-reference는 임시 객체를 넘겨 받은 참조를 그대로 돌려받는 경우까지 경고합니다.
null 포인터를 역참조해 참조 만들기
// 잘못된 코드
std::string* ptr = nullptr;
std::string& ref = *ptr; // 미정의 동작: null 포인터 역참조
이 코드는 컴파일 에러 없이 통과합니다. 참조가 null일 수 없다는 것은 언어가 검사해 준다는 뜻이 아니라, null 포인터를 역참조하는 순간 이미 미정의 동작이라는 뜻입니다. 대개 참조를 실제로 쓰는 나중 시점에 크래시가 나서 원인을 찾기 어렵습니다. 값이 없을 수 있다면 처음부터 포인터나 std::optional로 받고, 포인터를 참조로 바꾸기 전에 null 검사를 합니다.
rvalue를 비const lvalue 참조로 받으려 함
void process(int& x) { }
process(42); // 에러: 42는 rvalue
인자를 수정할 필요가 없다면 const int&나 int로 받고, rvalue를 받아 이동하고 싶다면 int&& 오버로드를 추가합니다. 단, process(const int&)와 process(int)를 동시에 선언하면 process(42)가 모호해지므로 둘 중 하나만 둡니다.
범위 기반 for에서 참조 누락
std::vector<int> vec = {1, 2, 3};
for (int x : vec) {
x *= 2; // vec 원소는 그대로
}
값으로 받으면 원소의 복사본을 바꿀 뿐입니다. 수정하려면 int&(또는 auto&), 복사 비용이 있는 원소를 읽기만 한다면 const auto&로 받습니다.
const 참조 인자를 그대로 참조로 반환
#include <iostream>
#include <string>
// 잘못된 사용
const std::string& identity(const std::string& s) {
return s;
}
int main() {
const std::string& ref = identity(std::string("temp")); // dangling
std::cout << ref << "\n"; // 미정의 동작
}
identity에 넘긴 임시 객체는 호출이 포함된 전체 식이 끝날 때 파괴됩니다. 반환된 참조를 ref에 묶어도, 그것은 임시 객체를 직접 묶은 것이 아니므로 수명 연장이 일어나지 않습니다. std::min, std::max처럼 const 참조를 받아 그중 하나를 참조로 돌려주는 표준 함수도 같은 함정이 있어, const auto& m = std::max(a, f());처럼 쓰면 f()가 반환한 임시 객체가 선택될 때 댕글링이 됩니다. 결과를 오래 쓸 생각이면 auto m = ...으로 값을 받습니다.
rvalue 참조 매개변수에서 std::move 누락
class Wrapper {
std::vector<int> data;
public:
Wrapper(std::vector<int>&& vec) : data(vec) {} // 복사가 일어남
// Wrapper(std::vector<int>&& vec) : data(std::move(vec)) {} // 올바른 코드: 이동
};
vec의 타입은 rvalue 참조지만 이름이 있으므로 식으로서는 lvalue입니다. 그래서 data(vec)은 복사 생성자를 호출합니다. 의도대로 이동하려면 std::move(vec)이 필요합니다. 코드가 컴파일되고 결과도 맞기 때문에 성능 문제로만 드러나는 실수이며, clang-tidy의 cppcoreguidelines-rvalue-reference-param-not-moved 검사로 잡을 수 있습니다.
getter, swap, 연산자 오버로딩에서의 참조
getter는 멤버를 복사하지 않도록 const 참조로 반환하는 경우가 많습니다. 반환된 참조는 객체가 살아 있는 동안만 유효하므로, 임시 객체에서 바로 꺼내 참조로 붙잡는 코드(const auto& k = makeConfig().getKeys();)는 댕글링이 된다는 점을 기억해야 합니다.
class Config {
std::vector<std::string> keys_;
public:
const std::vector<std::string>& getKeys() const {
return keys_; // 복사 없이 반환
}
};
swap은 참조로 받아 이동으로 교환합니다.
template <typename T>
void swap(T& a, T& b) {
T temp = std::move(a);
a = std::move(b);
b = std::move(temp);
}
연산자 오버로딩에서는 피연산자를 const 참조로 받고, 복합 대입 연산자는 연쇄 대입을 위해 *this의 참조를 반환합니다. 새 값을 만드는 operator+는 값으로 반환합니다.
class BigInt {
public:
BigInt operator+(const BigInt& other) const; // 새 값 반환
BigInt& operator+=(const BigInt& other); // *this 참조 반환
};
참고 자료
- cppreference - Reference declaration
- cppreference - Reference collapsing
- C++ Core Guidelines - F.15: 인자 전달 방식
- C++ Core Guidelines - F.45: 반환값 관련