1. RVO (Return Value Optimization)
지난 시간에는 Copy Elision의 한 종류인 Pass-by-Value 최적화를 살펴보았습니다.
이번에는 Copy Elision의 또 다른 대표적인 최적화 기법인 RVO(Return Value Optimization) 에 대해 알아보겠습니다.
설명을 단순하게 하기 위해 이번에도 std::string은 다음과 같은 구조를 가진다고 가정하겠습니다.
- 스택(Stack)에는 std::string 객체가 저장됩니다.
- 실제 문자열은 힙(Heap)에 저장됩니다.
- std::string 객체는 힙에 있는 문자열을 가리킵니다.
1.1 일반적으로 생각하는 동작
다음과 같은 함수를 생각해 보겠습니다.
std::string GetString()
{
std::string s = "Morgan";
return s;
}
그리고 다음과 같이 호출합니다.
std::string a = GetString();
직관적으로는 다음과 같은 과정이 일어날 것이라고 생각하기 쉽습니다.
- 함수 안에서 지역 변수 s가 생성됩니다.
- s가 반환될 때 a로 복사됩니다.
- 함수가 종료되면서 s가 소멸됩니다.
즉, 문자열 복사가 한 번 발생한다고 생각할 수 있습니다.
이 때문에 return std::move(s);처럼 std::move를 사용하는 경우도 있습니다.
하지만 대부분의 경우 이러한 코드는 오히려 필요하지 않습니다.
1.2 RVO (Return Value Optimization)
현대 C++ 컴파일러는 대부분 RVO(Return Value Optimization) 를 적용합니다.
RVO가 적용되면 컴파일러는 "반환된 객체가 최종적으로 어디에 저장될 지" 를 미리 알고 있습니다.
따라서 함수 내부에서 별도의 지역 객체를 만들고 이를 복사하는 대신, 처음부터 최종 목적지에 객체를 직접 생성합니다.
즉,
std::string a = GetString();
를 실행하면 내부적으로는 다음과 같이 동작합니다.
- a가 저장될 메모리 공간을 먼저 확보합니다.
- GetString() 내부에서 문자열을 바로 a의 공간에 생성합니다.
- 함수가 종료됩니다.
이 과정에서는
- 복사(Copy)가 발생하지 않습니다.
- Move도 발생하지 않습니다.
이를 Zero Copy, Zero Move라고 합니다.
1.3 std::move()를 사용할 필요가 없는 이유
많은 개발자가 다음과 같이 작성합니다.
return std::move(s);
하지만 RVO가 가능한 상황에서는 이 코드가 필요하지 않습니다.
return s;
만 작성해도 컴파일러가 RVO를 적용하여 가장 효율적인 코드를 생성합니다.
즉, 대부분의 경우 std::move를 직접 사용할 필요가 없습니다.
1.4 RVO가 적용되지 않는 경우
RVO는 모든 상황에서 적용되는 것은 아닙니다.
예를 들어 다음과 같은 코드를 살펴보겠습니다.
std::string GetString(bool flag)
{
std::string a = "Hello";
std::string b = "World";
if (flag)
return a;
else
return b;
}
이 경우에는
- 어떤 객체가 반환될지 컴파일러가 미리 결정할 수 없습니다.
따라서 RVO가 적용되지 않을 수도 있습니다.
1.5 RVO가 적용되지 않아도 Copy가 발생하지 않는 이유
RVO가 적용되지 않는 경우에도
return a;
에서는 대부분 Move Constructor가 호출됩니다.
즉,
Copy
↓
Move
로 최적화됩니다.
따라서 여전히 문자열 전체를 복사하지 않고 소유권만 이동하게 됩니다.
결과적으로 대부분의 경우 Copy는 발생하지 않습니다.
1.6 결론
함수에서 객체를 반환할 때는
return object;
처럼 그냥 반환하는 것이 가장 좋은 방법입니다.
컴파일러는 상황에 따라
- RVO를 적용하여 Copy와 Move를 모두 제거하거나,
- RVO가 불가능하면 Move Constructor를 사용하여 Copy를 방지합니다.
따라서 대부분의 경우
return std::move(object);
를 사용할 필요가 없습니다.
※ 정리
이번 장에서는 다음 내용을 학습했습니다.
- 함수의 인자를 전달하는 방법(Pass by Value, Pointer, Reference)
- L-value와 R-value의 개념
- L-value Reference와 R-value Reference의 차이
- std::move를 이용한 소유권 이동(Move)
- Copy Elision을 통한 최적화
- Pass-by-Value 최적화
- RVO(Return Value Optimization)
이러한 개념들은 다음 장에서 배우게 될
- Copy Constructor
- Move Constructor
- Copy Assignment
- Move Assignment
를 이해하는 데 중요한 기반이 됩니다.
예를 들어,
std::string s = "ABC";
std::string b = s; // Copy Constructor
std::string c = std::move(s); // Move Constructor
첫 번째는 문자열을 복사하여 새로운 객체를 생성하므로 Copy Constructor가 호출됩니다.
반면 두 번째는 std::move를 통해 소유권을 이동하므로 Move Constructor가 호출됩니다.
다음 장에서는 이러한 생성자와 대입 연산자가 실제로 어떻게 동작하는지 자세히 살펴보겠습니다.
2. Copy Elision(복사 생략)의 다양한 예제
예제 1: 기본 RVO (Tempory Return)
각 예제마다 호출 추적을 해서 복사 생략이 실제로 일어나는지 확인하겠습니다.
#include <iostream>
#include <string>
class Person {
public:
std::string name;
Person(const std::string& n) : name(n) {
std::cout << "✓ 생성자: " << name << "\n";
}
Person(const Person& other) : name(other.name) {
std::cout << "✗ 복사 생성자: " << name << "\n";
}
~Person() {
std::cout << "~ 소멸자: " << name << "\n";
}
};
// 임시 객체 반환
Person getPerson() {
return Person("Alice"); // 컴파일러가 복사 생략
}
int main() {
std::cout << "=== 예제 1: 기본 RVO ===\n";
Person p = getPerson();
// 출력 (C++17+):
// ✓ 생성자: Alice
// ~ 소멸자: Alice
// (복사 생성자 호출 안 됨!)
}
예제 2: NRVO (Named Local Variable)
Person createPerson() {
Person p("Bob"); // 지역 변수
return p; // NRVO로 복사 생략
}
int main() {
std::cout << "=== 예제 2: NRVO ===\n";
Person p = createPerson();
// 출력 (C++17+):
// ✓ 생성자: Bob
// ~ 소멸자: Bob
// (복사 생성자 호출 안 됨!)
}
예제 3: 직접 초기화 (Direct Initialization)
int main() {
std::cout << "=== 예제 3: 직접 초기화 ===\n";
// C++17 이상: 무조건 copy elision 발생
Person p = Person("Charlie"); // 임시 객체의 복사 생략
// 출력:
// ✓ 생성자: Charlie
// ~ 소멸자: Charlie
}
예제 4: 함수 호출 체이닝
Person processName(Person p) {
std::cout << " processName 함수 실행\n";
return p; // p를 반환
}
int main() {
std::cout << "=== 예제 4: 함수 체이닝 ===\n";
Person p = processName(Person("David"));
// 출력:
// ✓ 생성자: David
// processName 함수 실행
// ~ 소멸자: David
}
예제 5: 조건부 반환 (복사 생략 불가)
Person getPersonByCondition(int choice) {
Person alice("Alice");
Person bob("Bob");
if (choice == 1)
return alice; // ✗ 복사 생성자 호출
else
return bob; // ✗ 복사 생성자 호출
// 두 개의 다른 객체 중 선택하므로 elision 불가
}
int main() {
std::cout << "=== 예제 5: 조건부 반환 ===\n";
Person p = getPersonByCondition(1);
// 출력:
// ✓ 생성자: Alice
// ✓ 생성자: Bob
// ✗ 복사 생성자: Alice (choice==1이므로)
// ~ 소멸자: Alice
// ~ 소멸자: Bob
}
예제 6: std::move와 비교
int main() {
std::cout << "=== 예제 6: move vs copy elision ===\n\n";
std::cout << "A) Copy Elision:\n";
Person p1 = Person("Eve"); // 복사 생략
std::cout << "\nB) std::move 사용:\n";
Person temp("Frank");
Person p2 = std::move(temp); // 이동 생성자 호출
// 출력:
// A) Copy Elision:
// ✓ 생성자: Eve
//
// B) std::move 사용:
// ✓ 생성자: Frank
// → 이동 생성자 호출 (copy elision이 아님)
}
예제 7: 컨테이너 추가
#include <vector>
int main() {
std::cout << "=== 예제 7: 컨테이너 ===\n";
std::vector<Person> vec;
// C++17+: copy elision으로 추가
vec.push_back(Person("Grace")); // 복사 생략
// 출력:
// ✓ 생성자: Grace
// (vec에 바로 삽입, 복사 안 함)
}
예제 8: emplace_back (가장 효율적)
int main() {
std::cout << "=== 예제 8: emplace_back ===\n";
std::vector<Person> vec;
// 복사/이동 없이 제자리(in-place)에서 생성
vec.emplace_back("Henry");
// 출력:
// ✓ 생성자: Henry
// (가장 효율적 - 생성자만 호출)
}
예제 9: 여러 반환값 (구조화 바인딩)
#include <tuple>
std::tuple<Person, int> getData() {
return std::make_tuple(Person("Iris"), 30);
// tuple 내부의 Person도 copy elision 대상
}
int main() {
std::cout << "=== 예제 9: 구조화 바인딩 ===\n";
auto [person, age] = getData();
// 출력:
// ✓ 생성자: Iris
}
예제 10: 클래스 멤버 초기화
class Team {
Person leader;
public:
Team() : leader("Leader") // copy elision
{
std::cout << " Team 생성\n";
}
};
int main() {
std::cout << "=== 예제 10: 멤버 초기화 ===\n";
Team t;
// 출력:
// ✓ 생성자: Leader
// Team 생성
}
예제 11: 복잡한 객체 (벡터 포함)
#include <vector>
class Dataset {
public:
std::vector<int> data;
std::string name;
Dataset(const std::string& n, int size)
: name(n), data(size) {
std::cout << "✓ Dataset 생성: " << name
<< " (크기: " << size << ")\n";
}
Dataset(const Dataset& other)
: name(other.name), data(other.data) {
std::cout << "✗ Dataset 복사: " << name << "\n";
}
};
Dataset loadData() {
return Dataset("model.dat", 1000000); // 큰 벡터도 복사 안 함!
}
int main() {
std::cout << "=== 예제 11: 큰 객체 ===\n";
Dataset d = loadData();
// 출력:
// ✓ Dataset 생성: model.dat (크기: 1000000)
// (백만 개 정수 복사 회피!)
}
예제 12: C++17 필수 보장
int main() {
std::cout << "=== 예제 12: C++17 필수 보장 ===\n";
// C++17 이전: 복사 생성자 필요했음
// C++17 이후: 복사 생성자 없어도 됨!
Person p = Person("Jack"); // 복사 생성자 호출되지 않음
// delete를 사용해도 작동:
// Person(const Person&) = delete; // 복사 생성자 삭제
// Person p = Person("Jack"); // ✓ 작동함 (C++17+)
}
정리: 복사 생략 O/X
✓ 복사 생략 가능:
- RVO: return Person("name");
- NRVO: Person p("name"); return p;
- 임시 객체 초기화: Person p = Person("name");
- emplace_back()
- 멤버 초기화
✗ 복사 생략 불가:
- 조건부 반환: if() return a; else return b;
- 변수 전달: void func(Person p) { return p; }
- 다른 객체 반환: 사전에 정의된 여러 객체 중 선택
이 예제들을 실제로 컴파일해서 -fno-elide-constructors 플래그와 함께 실행하면 copy elision이 제거될 때 복사 생성자가 호출되는 차이를 볼 수 있습니다!
'Fundamental of CS > : : C++' 카테고리의 다른 글
| [OOP] OOP, Class (0) | 2024.07.16 |
|---|---|
| [OOP] C++ OOP Intro (0) | 2024.07.16 |
| [Resource Move] C++의 포인터와 Rust의 소유권 (0) | 2024.07.16 |
| [Resource Move] std::move() (0) | 2024.07.16 |
| [Resource Move] L-Value, R-Value 그리고 std::move (0) | 2024.07.16 |