Fundamental of CS/: : C++

[Resource Move] Return Value Optimization(RVO)

Jay.P Morgan 2024. 7. 16. 10:57

 

  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();

직관적으로는 다음과 같은 과정이 일어날 것이라고 생각하기 쉽습니다.

  1. 함수 안에서 지역 변수 s가 생성됩니다.
  2. s가 반환될 때 a로 복사됩니다.
  3. 함수가 종료되면서 s가 소멸됩니다.

즉, 문자열 복사가 한 번 발생한다고 생각할 수 있습니다.

이 때문에 return std::move(s);처럼 std::move를 사용하는 경우도 있습니다.

하지만 대부분의 경우 이러한 코드는 오히려 필요하지 않습니다.

 

 

  1.2  RVO (Return Value Optimization)

 

현대 C++ 컴파일러는 대부분 RVO(Return Value Optimization) 를 적용합니다.

RVO가 적용되면 컴파일러는 "반환된 객체가 최종적으로 어디에 저장될 지" 를 미리 알고 있습니다.

따라서 함수 내부에서 별도의 지역 객체를 만들고 이를 복사하는 대신, 처음부터 최종 목적지에 객체를 직접 생성합니다.

즉,

std::string a = GetString();

를 실행하면 내부적으로는 다음과 같이 동작합니다.

  1. a가 저장될 메모리 공간을 먼저 확보합니다.
  2. GetString() 내부에서 문자열을 바로 a의 공간에 생성합니다.
  3. 함수가 종료됩니다.

이 과정에서는

  • 복사(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;

처럼 그냥 반환하는 것이 가장 좋은 방법입니다.

컴파일러는 상황에 따라

  1. RVO를 적용하여 Copy와 Move를 모두 제거하거나,
  2. 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