1. L-value, R-value 그리고 std::move
이전 글에서는 L-value 레퍼런스와 R-value 레퍼런스에 대해 알아보았습니다. 이번 시간에는 먼저 L-value와 R-value가 무엇인지 살펴보고, L-value를 R-value로 변환하는 std::move에 대해 설명드리겠습니다.
1.1 L-value와 R-value란?
L-value와 R-value는 생각보다 어려운 개념이 아닙니다.
예를 들어 다음과 같은 코드가 있다고 하겠습니다.
int a = 0;
- a는 이후에도 여러 번 사용할 수 있는 변수이므로 L-value입니다.
- 0은 이 문장에서만 사용되는 임시 값이므로 R-value입니다.
또 다른 예를 보겠습니다.
int b = a;
이 경우 b와 a는 모두 이름이 있는 변수이므로 L-value입니다.
즉,
- 여러 번 참조할 수 있는 값은 L-value
- 한 번만 사용되고 다시 참조할 수 없는 임시 값은 R-value
라고 이해하시면 됩니다.
문자열도 동일합니다.
std::string s = "ABC";
여기서
- s는 다시 사용할 수 있으므로 L-value
- "ABC"는 임시 문자열 리터럴이므로 R-value입니다.
1.2 std::move
std::move는 L-value를 R-value로 변환하는 함수입니다.
예를 들어,
std::string c;
std::string d = std::move(c);
- c 자체는 L-value입니다.
- 하지만 std::move(c)를 사용하면 R-value로 변환됩니다.
std::move를 호출한 이후에는 해당 객체를 다시 사용할 수는 있지만, 값이 이동(move)된 상태이므로 기존 데이터를 그대로 가지고 있다고 가정해서는 안 됩니다.
1.3 L-value와 R-value가 함수에서 사용되는 방법
이번에는 문자열(std::string)을 이용하여 설명드리겠습니다.
- 스택(Stack)에는 포인터가 저장됩니다.
- 실제 문자열은 힙(Heap)에 저장됩니다.
- 스택의 포인터가 힙의 문자열을 가리킵니다.
설명을 단순하게 하기 위해 std::string은 다음과 같은 구조라고 가정하겠습니다.
실제 std::string은 구현이 더 복잡하지만(예: SSO, Small String Optimization), 복사와 move의 개념을 설명하기 위해 아래처럼 생각하면 됩니다.
스택(Stack)
+----------------+
| a |
| ptr ----------+--------------------+
+----------------+ |
▼
힙(Heap)
+----------------+
| 'A' 'B' 'C' |
+----------------+
여기서 중요한 것은 변수 a가 문자열 자체를 들고 있는 것이 아니라, 문자열이 저장된 힙 메모리의 주소(포인터)를 가지고 있다는 점입니다.
예를 들어
std::string a = "ABC";
를 실행하면 메모리에는 다음과 같이 저장된다고 생각하면 됩니다.
스택
+----------------------+
| a |
| ptr = 0x1000 --------+------+
+----------------------+ |
|
힙 ▼
0x1000 : [A][B][C][\0]
즉,
- 스택에는 std::string 객체(a)가 있습니다.
- a 안에는 "ABC"가 저장된 힙 메모리의 주소가 들어 있습니다.
- 실제 문자열 "ABC"는 힙에 저장됩니다.
Q. 왜 이런 설명을 하나요?
- A. 이제 함수에 전달하는 경우를 보면 이해가 쉬워집니다.
(1) 값(Value)으로 전달
void func(std::string s);
std::string a = "ABC";
func(a);
이 경우에는 a를 통째로 복사해야 합니다.
원래
a -----> "ABC"
복사 후
a -----> "ABC"
s -----> "ABC" (새로운 메모리)
즉, 힙에 "ABC"가 하나 더 생깁니다.
(2) L-value Reference
void func(std::string& s);
func(a);
이번에는 복사하지 않습니다.
a -----> "ABC"
s -----+
|
+-------> "ABC"
a와 s가 같은 문자열을 바라보는 것입니다.
그래서 문자열 복사가 발생하지 않습니다.
(3) R-value Reference + move
void func(std::string&& s);
func(std::move(a));
여기서 move는 문자열을 복사하는 것이 아니라
a -----> "ABC"
↓
s -----> "ABC"
a -----> (비어있는 상태)
처럼 포인터만 넘겨주는 것이라고 이해하면 됩니다.
즉,
- "ABC"를 새로 복사하지 않습니다.
- 힙에 있는 메모리를 그대로 가져다 사용합니다.
그래서 copy보다 훨씬 빠른 것입니다.
한 줄로 정리하면
강의에서 스택과 힙을 설명한 이유는 복사가 언제 일어나는지를 보여주기 위해서입니다.
- Value 전달 → 문자열 데이터가 새로 복사됨.
- L-value Reference 전달 → 같은 문자열을 함께 참조함.
- R-value Reference + std::move → 문자열을 복사하지 않고 소유권(포인터)을 옮김.
이렇게 생각하시면 이후에 나오는 "복사는 2번, 1번, 0번"이라는 설명이 자연스럽게 이해될 것입니다.
1.4 값을 전달하는 방법 비교
다음과 같이 세 가지 함수를 생각해 보겠습니다.
storeByValue(std::string s);
storeByLReference(std::string& s);
storeByRReference(std::string&& s);
다음 호출을 살펴보겠습니다.
storeByValue(a);
storeByLReference(a);
storeByRReference(a); // 오류
마지막 코드는 컴파일 오류가 발생합니다.
그 이유는 storeByRReference()는 R-value만 받을 수 있는데, a는 L-value이기 때문입니다.
이 문제는 다음과 같이 해결할 수 있습니다.
storeByRReference(std::move(a));
또는
storeByRReference("ABC");
처럼 처음부터 R-value를 전달하면 됩니다.
1.5 값 전달 방식에 따른 복사 횟수
(1) Value 전달
storeByValue(a);
과정은 다음과 같습니다.
- a의 문자열이 함수 매개변수 s로 복사됩니다.
- 다시 s가 지역 변수로 복사됩니다.
즉,
- 복사가 총 2번 발생합니다.
(2) L-value Reference 전달
storeByLReference(a);
이 경우에는
- 함수의 매개변수가 기존 문자열을 그대로 참조합니다.
- 지역 변수에 저장할 때만 복사가 발생합니다.
즉,
- 복사가 1번 발생합니다.
(3) R-value Reference 전달
storeByRReference(std::move(a));
여기서는 R-value 레퍼런스가 임시 객체를 그대로 참조합니다.
하지만 함수 안에서
std::string b = s;
처럼 그대로 사용하면 s는 이름이 있는 변수이므로 L-value가 됩니다.
따라서 이 코드는 복사(copy) 가 일어납니다.
이를 방지하려면 다음과 같이 작성해야 합니다.
std::string b = std::move(s);
이렇게 하면 s가 다시 R-value로 변환되어 복사 대신 이동(move) 이 수행됩니다.
결과적으로
- 복사가 발생하지 않고(move) 데이터만 이동하게 됩니다.
1.6 정리
이번 시간에는 다음 내용을 살펴보았습니다.
- L-value는 여러 번 참조할 수 있는 값입니다.
- R-value는 일시적으로만 존재하는 임시 값입니다.
- std::move는 L-value를 R-value로 변환해 줍니다.
- 값을 복사해서 전달하는 것보다 레퍼런스를 사용하는 것이 더 효율적입니다.
- 특히 R-value 레퍼런스와 std::move를 활용하면 불필요한 복사를 줄이고, 경우에 따라 복사 없이(move) 데이터를 전달할 수 있습니다.
다음 시간에는 오늘 배운 내용을 조금 더 자세히 살펴보고, std::move의 동작 원리와 활용 방법을 더욱 깊이 있게 알아보겠습니다. 감사합니다.
1. Call by Value, Pointer, Reference
1.1 Intro
이전 챕터에서는
- 프로세스 메모리 모델
- 빌드 프로세스
를 학습했다.
이번 챕터는 객체지향 프로그래밍(OOP)을 배우기 전에 반드시 알아야 하는 기초 개념을 다룹니다.
이후 학습 순서는 다음과 같습니다.
포인터
↓
레퍼런스
↓
Lvalue / Rvalue
↓
std::move
↓
Move Constructor
↓
Return Value Optimization(RVO)
↓
클래스(Object Oriented Programming)
이번에 그 첫 단계인 포인터(Pointer)와 레퍼런스(Reference)를 학습합니다.
1.2 함수의 인자 전달 방식
C++에서는 함수에 값을 전달하는 방법이 크게 세 가지입니다.
| 전달 방식 | 특징 |
| Pass by Value | 값을 복사해서 전달 |
| Pass by Pointer | 주소를 전달 |
| Pass by Reference | 객체 자체를 참조 |
이 세 방식은 메모리 사용 방식과 성능이 서로 다릅니다.
1.3 Pass by Value (값 전달)
함수 호출 시 값을 복사하여 전달하는 방식.
예시
void Foo(int a)
{
int b = a + 1;
}
int main()
{
int a = 0;
Foo(a);
}
메모리 동작
main Stack Frame
a = 0
│
│ 복사
▼
Foo Stack Frame
a = 0
b = 1
중요한 점은 Foo의 a와 main의 a는 서로 다른 변수라는 것입니다.
따라서
a = 100;
을 Foo 내부에서 수행해도, main의 a는 변경되지 않습니다.
장점
- 구현이 가장 단순합니다.
- 원본 데이터를 보호할 수 있습니다.
- 작은 자료형(int, char, bool 등)에서는 오버헤드가 거의 없습니다.
단점
복사가 발생.
예를 들어
std::vector<int>
std::string
사용자 정의 클래스
같은 큰 객체는 복사 비용이 상당히 큽니다.
1.4 Pass by Pointer (포인터 전달)
객체의 주소(Address)를 전달합니다.
void Foo(int* p)
{
int b = *p + 1;
}
호출은
Foo(&a);
처럼 주소를 넘깁니다.
메모리 구조
main
a = 0
주소 : 1000
▲
│
│
Foo
p = 1000
Foo는 포인터를 통해 원본 변수에 직접 접근합니다.
원본 수정 가능
예를 들어
*p = 100;
을 수행하면
main
a = 100
으로 변경됨.
즉, 포인터는 원본 객체를 직접 수정할 수 있다.
장점
- 복사가 발생하지 않는다.
- 큰 객체를 효율적으로 전달할 수 있다.
- 원본 수정 가능
단점
포인터는 위험합니다.
예를 들어
nullptr
일 수도 있고 잘못된 주소를 가질 수도 있습니다.
int* p = nullptr;
*p = 10;
이와 같은 같은 코드는 프로그램이 비정상 종료될 수 있습니다.
1.5 Pass by Reference (참조 전달)
객체의 별칭(Alias)을 전달한다.
void Foo(int& a)
{
int b = a + 1;
}
호출은
Foo(a);
입니다.
문법은 값 전달처럼 보이지만, 실제로는 원본 객체를 참조합니다.
* 내부 동작
Compiler Explorer로 확인하면
Pass by Pointer
↓
Assembly
↓
Pass by Reference
의 어셈블리 코드가 거의 동일하게 생성됩니다.
즉, 레퍼런스는 내부적으로 포인터와 거의 동일한 방식으로 구현됩니다.
차이점
문법만 다릅니다.
포인터
*p
레퍼런스
a
레퍼런스는 역참조(*)가 필요 없습니다.
1.6 포인터와 레퍼런스 비교
| 항목 | Pointer | Reference |
| nullptr 가능 | O | X |
| 재참조 가능 | O | X |
| 주소 변경 가능 | O | X |
| 문법 | 복잡 | 간단 |
| 안전성 | 낮음 | 높음 |
1.7 [General-rule] *왜 실무에서는 Reference를 많이 사용할까?
포인터가 반드시 필요한 상황이 아니라면 Reference를 사용하는 것이 좋다.
그 이유는 레퍼런스가
- nullptr가 될 수 없고
- 문법이 간단하며
- 실수를 줄일 수 있기 때문이다.
즉, 더 안전한 코드를 작성할 수 있다.
1.8 [모범사례: 안정성] const Reference
만약 함수가 객체를 읽기만 한다면
void Foo(const int& a)
{
}
처럼 작성합니다.
장점
컴파일러가
a = 10;
같은 코드를 허용하지 않습니다.
즉, 원본 데이터가 변경되지 않음을 보장하죠.
실무에서 가장 많이 사용하는 형태
void Print(const std::string& str);
거의 모든 라이브러리가 이 방식을 사용합니다.
1.9 [효율성과 성능] 큰 객체 전달
이 글에서 중요한 성능 이야기 입니다.
예를 들어
std::vector<int>
100000개
를 Pass by Value 하면 [100000개 복사] 가 발생하죠.
하지만 Reference를 사용하면, [주소 하나만 전달] 하면 됩니다.
즉, 객체 크기와 관계없이, 복사 비용이 없죠.
1.10 왜 Value 전달을 사용하는 경우도 있을까?
많은 초보자가 "Reference가 더 빠른데 모두 Reference를 쓰면 되지 않을까?" 라고 생각합니다.
하지만 그렇지 않습니다.
예를 들어
int
double
char
bool
같은 작은 타입은 복사 비용이 거의 없습니다.
오히려 Reference는 간접 접근(Indirect Access)이 필요하기 때문에 Value가 더 효율적인 경우도 있다.
실무에서는 일반적으로 다음 기준을 사용합니다.
| 자료형 | 추천 |
| int | Value |
| bool | Value |
| enum | Value |
| std::string | const Reference |
| std::vector | const Reference |
| 사용자 정의 클래스 | const Reference |
1.11 다음 챕터와의 연결
std::vector::push_back() 예시
push_back(const T&);
와
push_back(T&&);
두 함수가 존재합니다.
여기서 & 는 L-value Reference 이고, && 는 R-value Reference 이다.
현재는 차이를 이해하기 어렵겠지만, 다음 글에서 L-value와 R-value를 배우면 왜 함수가 두 개 존재하는지 이해하게 됩니다.
1.12 실무에서의 활용
실제 프로젝트에서는 함수 인자를 다음과 같이 선택하는 경우가 많습니다.
// 작은 타입
void SetAge(int age);
// 읽기 전용 큰 객체
void Print(const std::string& name);
// 수정 가능한 객체
void Sort(std::vector<int>& nums);
// 소유권 이전(다음 챕터)
void SetData(std::vector<int>&& data);
이처럼 객체의 크기와 수정 여부에 따라 전달 방식을 선택하는 것이 성능과 코드 안정성 모두에 중요하다.
* 핵심포인트
Q1. Pass by Value와 Pass by Reference의 차이는?
- Pass by Value는 값을 복사하므로 원본이 변경되지 않는다.
- Pass by Reference는 원본 객체를 직접 참조하므로 복사 비용이 없고 원본을 수정할 수 있다.
Q2. Reference가 Pointer보다 좋은 이유는?
- nullptr가 될 수 없고, 문법이 간단하며, 실수를 줄일 수 있어 더 안전하다. 포인터가 꼭 필요한 경우(동적 메모리, 선택적 참조 등)가 아니라면 Reference를 사용하는 것이 일반적이다.
Q3. const T&를 사용하는 이유는?
- 큰 객체를 복사하지 않고 전달하면서도 원본 수정은 방지할 수 있기 때문이다. 성능과 안정성을 모두 확보할 수 있어 실무에서 가장 많이 사용하는 함수 인자 형태이다.
※ 핵심 정리
- 함수 인자 전달 방식은 Pass by Value, Pass by Pointer, Pass by Reference 세 가지가 있다.
- Value는 복사가 발생하지만 원본을 보호하며, 작은 타입에 적합하다.
- Pointer는 주소를 전달하여 원본을 수정할 수 있지만 nullptr 등으로 인해 오류 가능성이 높다.
- Reference는 포인터와 거의 동일한 성능을 가지면서도 더 안전하고 사용하기 쉬워, 실무에서는 포인터보다 선호된다.
- const T&는 큰 객체를 효율적이고 안전하게 전달하는 가장 일반적인 방식이며, 현대 C++에서 매우 중요한 패턴이다.
- 다음 단계에서는 L-value / R-value를 배우며, 이를 바탕으로 Move Semantics와 성능 최적화를 이해하게 된다.
'Fundamental of CS > : : C++' 카테고리의 다른 글
| [Resource Move] C++의 포인터와 Rust의 소유권 (0) | 2024.07.16 |
|---|---|
| [Resource Move] std::move() (0) | 2024.07.16 |
| [Resource Move] C++ Value, Pass by Value / Pointer / Reference (0) | 2024.07.16 |
| [Compile Process] C++ Code Formatter (Clang-Format) (0) | 2024.07.16 |
| [Compile Process] Unit Test, static code analysis (0) | 2024.07.16 |