C++ Copy Elision 심화 | RVO·NRVO·필수 생략·예외 안전
이 글의 핵심
복사 생략은 C++17 이후 문맥에 따라 의무가 되기도 하고, NRVO처럼 컴파일러 재량으로 남기도 합니다. 필수/선택, RVO·NRVO 조건, 예외 안전, 실무 패턴을 한 번에 정리합니다.
복사 생성자 호출이 사라지는 순간
Copy Elision(복사 생략)은 객체를 반환하거나 전달할 때 생길 복사/이동 생성자 호출을 없애는 규칙입니다. C++17부터 prvalue로 초기화하는 경우에는 표준이 이를 보장하고, 이름 있는 지역 변수를 반환하는 경우(NRVO)에는 여전히 컴파일러 재량입니다.
#include <iostream>
class Widget {
public:
Widget() {
std::cout << "생성자" << std::endl;
}
// 복사 생성자: 다른 Widget으로부터 복사
Widget(const Widget&) {
std::cout << "복사 생성자" << std::endl;
}
// 이동 생성자: 다른 Widget의 리소스를 이동
Widget(Widget&&) noexcept {
std::cout << "이동 생성자" << std::endl;
}
};
Widget createWidget() {
// Widget() 임시 객체 반환
// Copy Elision: 복사/이동 없이 직접 반환 위치에 생성
return Widget(); // 복사/이동 생략
}
int main() {
// createWidget()의 반환값을 w에 할당
// Copy Elision 적용: Widget이 w의 위치에 직접 생성됨
// 복사 생성자도, 이동 생성자도 호출 안 됨!
Widget w = createWidget();
// 출력: "생성자" (한 번만)
// 복사/이동 생략으로 성능 향상
}
이 규칙 덕분에 컨테이너나 문자열 같은 큰 객체도 출력 매개변수 없이 값으로 반환하는 코드를 부담 없이 쓸 수 있습니다. 다만 어디까지가 보장이고 어디부터가 최적화인지 구분하지 않으면, 컴파일러나 빌드 설정이 바뀔 때 이동 호출이 갑자기 나타나는 이유를 설명하기 어렵습니다.
RVO·NRVO·인자 전달 세 가지 생략
RVO: 임시 객체를 반환할 때
임시 객체를 반환할 때 적용됩니다. C++17부터 필수입니다.
#include <iostream>
class Data {
public:
Data() {
std::cout << "생성자" << std::endl;
}
Data(const Data&) {
std::cout << "복사 생성자" << std::endl;
}
Data(Data&&) noexcept {
std::cout << "이동 생성자" << std::endl;
}
};
Data func() {
return Data(); // prvalue (순수 우측값)
}
int main() {
Data d = func();
// C++17: 복사 생략 보장
// 출력: "생성자" (1번)
}
NRVO: 이름 있는 지역 변수를 반환할 때
이름 있는 지역 변수를 반환할 때 적용됩니다. 컴파일러 재량입니다.
#include <iostream>
class Data {
public:
Data() {
std::cout << "생성자" << std::endl;
}
Data(const Data&) {
std::cout << "복사 생성자" << std::endl;
}
Data(Data&&) noexcept {
std::cout << "이동 생성자" << std::endl;
}
};
Data func() {
// 이름 있는 지역 변수 (Named Return Value)
Data d; // 이름 있는 객체
// d 초기화 로직
// NRVO (Named Return Value Optimization):
// 컴파일러가 d를 반환 위치에 직접 생성할 수 있음
// 하지만 RVO와 달리 보장되지 않음 (컴파일러 재량)
return d;
}
int main() {
Data d = func();
// NRVO 적용 시: "생성자" (1번만)
// - func() 안의 d가 main의 d 위치에 직접 생성
// NRVO 미적용 시: "생성자" + "이동 생성자"
// - func() 안에서 생성 → main으로 이동
}
NRVO가 걸리려면 반환되는 객체가 함수의 지역 변수(매개변수는 제외)여야 하고, 그 타입이 반환 타입과 같아야 합니다. 표준이 요구하는 것은 여기까지지만, 실제 컴파일러는 모든 반환 경로가 같은 변수를 반환할 때 가장 안정적으로 NRVO를 적용합니다.
// ✅ NRVO 가능
Data func1() {
Data d;
return d; // 항상 d 반환
}
// ❌ NRVO 불가
Data func2(bool flag) {
Data a, b;
return flag ? a : b; // 조건 연산자 결과는 이름이 아니라 lvalue 식 → 복사
}
임시 객체를 인자로 넘길 때
임시 객체를 함수 인자로 전달할 때 적용됩니다.
#include <iostream>
class Widget {
public:
Widget() {
std::cout << "생성자" << std::endl;
}
Widget(const Widget&) {
std::cout << "복사 생성자" << std::endl;
}
Widget(Widget&&) noexcept {
std::cout << "이동 생성자" << std::endl;
}
};
void process(Widget w) {
std::cout << "process 호출" << std::endl;
}
int main() {
process(Widget()); // 복사 생략
// Widget이 process의 매개변수 위치에 직접 생성됨
// 출력: "생성자", "process 호출"
}
복사 생략의 두 층: 필수(의무)와 선택(구현 재량)
표준에서 말하는 복사 생략은 한 덩어리가 아닙니다. 문법·의미론이 요구하는 생략과, 같은 관찰 가능 동작을 유지하면서 복사/이동 호출을 줄이는 최적화가 나뉩니다.
필수(의무) 복사 생략이 되는 대표 문맥 (C++17~)
C++17은 특정 초기화·return 문맥에서 prvalue가 결과 객체(result object)를 직접 초기화한다고 규정합니다. 이 경우 복사/이동 생성자는 아예 호출 대상이 아니므로, 복사와 이동 생성자를 모두 = delete해도 프로그램이 성립합니다(아래 NonCopyable 예제).
대표적으로 다음이 여기에 해당합니다.
- 함수 호출의 prvalue 반환이 반환 타입으로 직접 이어지는 경우(
return T();,return T{a,b};등 임시 객체가 곧바로 반환되는 형태). - 변수 선언에서 동일 타입의 prvalue로 직접 초기화하는 경우(
T x = T();등, 임시→임시 연쇄가 아닌 한 번의 결과 객체 생성).
이때 생략은 “컴파일러가 친절히 해줌” 수준이 아니라, 프로그램이 의미를 갖기 위한 규칙에 가깝습니다.
선택(구현 정의·재량) 복사 생략
이름 있는 자동 저장 기간 객체를 반환할 때의 NRVO는 전형적으로 여기에 해당합니다. 표준은 구현이 동일한 관찰 가능 동작을 유지하는 한 복사를 생략할 수 있음을 허용하지만, C++17의 prvalue 규정과 같은 강한 보장을 모든 NRVO에 두지는 않습니다.
또한 서로 다른 지역 변수를 조건에 따라 반환한다거나, 반환 식이 단일 이름으로 귀결되지 않는 경우에는 NRVO 자체가 성립하기 어렵으며, 이동 또는 복사로 폴백하는 모델이 전개됩니다.
디버깅 플래그와의 관계 (-fno-elide-constructors 등)
GCC·Clang의 -fno-elide-constructors는 선택적 생략을 꺼서 복사/이동 생성자 호출을 관찰하려는 용도로 씁니다. C++17 이후 의무 생략 문맥에는 이 플래그가 영향을 주지 않으므로, 어떤 반환이 보장된 생략인지 확인하려면 복사·이동 생성자를 삭제한 타입으로 컴파일해 보는 편이 확실합니다. 의무 생략은 표준이 객체를 어디에 만들지를 정하는 규칙이고, 선택 생략은 같은 동작을 유지하면서 비용을 줄이는 최적화입니다.
RVO와 NRVO: 조건·실패·표준 관점 정리
RVO(임시 반환)가 단순해 보이는 이유
C++17 이후, return의 피연산자가 반환 타입과 같은 타입의 prvalue이면 그 표현식은 함수의 반환값으로 쓰일 결과 객체를 직접 초기화합니다. 이는 이름이 없는 반환에서 특히 깔끔합니다.
// prvalue가 반환 타입 T의 결과 객체를 직접 초기화하는 모델(C++17~)
T f() {
return T{}; // RVO/의무 생략 문맥(구현이 아니라 규칙)
}
NRVO(이름 있는 반환)가 까다로운 이유
NRVO는 “지역 변수 x가 곧 호출자 쪽 결과 객체와 같은 저장을 쓴다”는 형태의 최적화입니다. 따라서 대략 다음이 성립하기 쉽습니다.
NRVO는 반환 식이 지역 변수의 이름 그 자체(return x;)이고, 그 변수의 타입이 반환 타입과 같을 때만 허용됩니다. return std::move(x);는 반환 식이 이름이 아니라 함수 호출 결과이므로 NRVO 대상에서 빠지고, 항상 이동이 일어납니다.
반대로 경로마다 서로 다른 지역 객체를 반환하면 컴파일러가 두 객체를 같은 반환 슬롯에 둘 수 없어 NRVO를 포기하는 경우가 많고, 이때는 이름을 반환하는 것이므로 암시적 이동으로 폴백합니다. return cond ? a : b;는 반환 식이 이름이 아닌 lvalue 식이라 NRVO도 암시적 이동도 적용되지 않아 복사가 일어납니다. 지역 변수의 타입과 반환 타입이 다르면(예: Derived를 만들어 Base로 반환) NRVO는 불가능하고, Base의 생성자로 변환(슬라이싱)되면서 객체가 하나 더 만들어집니다.
실무에서는 “한 번에 하나의 result만 만들고 모든 경로에서 return result;로 모은다”는 형태가 NRVO 가능성을 높입니다.
RVO와 NRVO 한 줄 요약
| 구분 | 대상 | C++17 이후의 느낌 |
|---|---|---|
| RVO | return의 prvalue | 특정 문맥에서 의미론이 직접 초기화를 요구 |
| NRVO | 이름 있는 지역 return x; | 흔히 선택 최적화(컴파일러·최적화 수준·코드 형태에 의존) |
C++17이 보장하는 prvalue 복사 생략
prvalue를 반환할 때 생성자가 몇 번 불리나
#include <iostream>
class Widget {
public:
Widget() {
std::cout << "생성자" << std::endl;
}
Widget(const Widget&) {
std::cout << "복사 생성자" << std::endl;
}
Widget(Widget&&) noexcept {
std::cout << "이동 생성자" << std::endl;
}
};
Widget createWidget() {
return Widget();
}
int main() {
// C++17부터 보장
Widget w1 = Widget(); // 복사 생략 (보장)
Widget w2 = createWidget(); // 복사 생략 (보장)
Widget w3 = Widget(Widget()); // 복사 생략 (보장)
// 출력: "생성자" (3번만)
}
C++17 복사 생략 규칙
| 상황 | C++14 이전 | C++17 이후 |
|---|---|---|
Widget w = Widget(); | 최적화 (선택) | 필수 |
Widget w = func(); (prvalue 반환) | 최적화 (선택) | 필수 |
return x; (이름 있는 지역 변수, NRVO) | 최적화 (선택) | 최적화 (선택) |
Widget w = x; (lvalue로 초기화) | 복사 | 복사 |
복사·이동 생성자가 삭제돼도 컴파일되는 이유
C++17부터 prvalue 복사 생략은 필수이므로, 복사 생성자와 이동 생성자가 모두 없어도 됩니다. C++14로 컴파일하면 아래 코드는 삭제된 이동 생성자 때문에 에러입니다.
#include <iostream>
class NonCopyable {
public:
NonCopyable() {
std::cout << "생성자" << std::endl;
}
NonCopyable(const NonCopyable&) = delete; // 복사 금지
NonCopyable(NonCopyable&&) = delete; // 이동도 금지
};
NonCopyable func() {
return NonCopyable(); // C++17: OK (복사 생략 보장)
}
int main() {
NonCopyable obj = func(); // OK
// 출력: "생성자"
}
C++17 “보장”이 바꾼 초기화 모델 (prvalue와 결과 객체)
C++17 이전에는 “임시 객체를 만들고 복사/이동으로 옮긴다”가 표준의 모델이었고, 생략은 그 단계를 지워도 된다는 허가였습니다. C++17 이후에는 prvalue가 임시 객체를 거치지 않고 결과 객체를 직접 초기화하는 것이 모델 자체입니다. 그래서 복사·이동이 불가능한 타입(std::mutex를 멤버로 가진 타입 등)도 팩토리 함수에서 값으로 반환할 수 있게 되었습니다. 구현 차원에서는 호출자가 결과 객체의 주소를 숨은 인자로 넘기고 피호출자가 그 자리에 객체를 구성하는 방식이 일반적이며, 이것이 표준 의미론과 그대로 대응합니다.
복사 생략과 예외 안전성
복사 생략은 성능만 이야기하는 것이 아니라, 예외가 나올 때 어떤 객체가 어느 시점에 존재하는지와도 맞닿습니다.
생성 도중 예외: 호출자 쪽 객체는?
의무 생략 문맥에서 T의 생성자가 예외를 던지면, 호출자가 받을 결과 객체는 완전히 생성되지 않은 채로 제어가 빠져나갑니다. 즉 “반환은 됐는데 내용물은 반쯤” 같은 상태가 표준 모델에서 정상적으로 남지 않습니다. 예외가 스택 풀기를 수행하는 동안 부분적으로 생성된 서브객체만 존재할 수는 있으나, 그건 해당 타입의 불변식을 설계할 때 생성자·멤버 초기화 순서로 다루는 문제입니다.
NRVO가 실패할 때: 이동 폴백과 noexcept
NRVO가 적용되지 않으면 return x;는 먼저 x를 rvalue로 보고 이동 생성자를 찾고, 이동이 불가능할 때만 복사합니다(암시적 이동). 따라서 반환 경로에서 예외가 날 수 있는지는 그 타입의 이동 생성자가 결정합니다. 이동 생성자를 noexcept로 두어야 하는 더 큰 이유는 반환이 아니라 컨테이너입니다. std::vector는 재할당할 때 std::move_if_noexcept로 원소를 옮기므로, 이동 생성자가 noexcept가 아니면 강한 예외 보장을 지키려고 이동 대신 복사합니다.
return std::move(x);는 NRVO를 막아 생략될 수 있었던 이동을 항상 일으킵니다. 이동이 싸다고 해도 호출 하나가 늘어나는 것이고, 이동 생성자가 예외를 던질 수 있는 타입이라면 굳이 예외 경로를 하나 더 만드는 셈입니다.
return std::move·여러 반환 경로·최적화 레벨 함정
return std::move(x)가 NRVO를 막는다
#include <iostream>
#include <vector>
class Data {
public:
Data() {
std::cout << "생성자" << std::endl;
}
Data(const Data&) {
std::cout << "복사 생성자" << std::endl;
}
Data(Data&&) noexcept {
std::cout << "이동 생성자" << std::endl;
}
};
// ❌ std::move로 복사 생략 방해
Data bad() {
Data d;
return std::move(d); // 복사 생략 불가, 이동만 발생
}
// ✅ 그냥 반환
Data good() {
Data d;
return d; // NRVO, 안 되면 암시적 이동
}
int main() {
std::cout << "=== bad() ===" << std::endl;
auto d1 = bad(); // 생성자 + 이동
std::cout << "\n=== good() ===" << std::endl;
auto d2 = good(); // 생성자만 (복사 생략)
return 0;
}
출력:
=== bad() ===
생성자
이동 생성자
=== good() ===
생성자
반환 경로가 여러 개면 NRVO가 빠진다
#include <iostream>
class Data {
public:
Data() {
std::cout << "생성자" << std::endl;
}
Data(const Data&) {
std::cout << "복사 생성자" << std::endl;
}
Data(Data&&) noexcept {
std::cout << "이동 생성자" << std::endl;
}
};
// ❌ 복사 생략 안 됨
Data bad(bool flag) {
Data a, b;
return flag ? a : b; // 이름이 아닌 lvalue 식이라 암시적 이동도 없음 → 복사
}
// ✅ 복사 생략 가능
Data good(bool flag) {
Data result;
if (flag) {
// result 초기화
} else {
// result 초기화
}
return result; // 복사 생략
}
int main() {
std::cout << "=== bad(true) ===" << std::endl;
auto d1 = bad(true); // 생성자 2번 + 복사
std::cout << "\n=== good(true) ===" << std::endl;
auto d2 = good(true); // 생성자만
return 0;
}
출력:
=== bad(true) ===
생성자
생성자
복사 생성자
=== good(true) ===
생성자
-fno-elide-constructors로 확인하기
GCC와 Clang은 -O0에서도 NRVO를 적용하므로 최적화 레벨만 바꿔서는 차이가 잘 보이지 않습니다. 선택적 생략을 끄려면 플래그를 직접 줍니다.
# 선택적 복사 생략(NRVO) 비활성화, C++17 의무 생략은 그대로
g++ -std=c++17 -fno-elide-constructors main.cpp -o main
# 기본
g++ -std=c++17 main.cpp -o main
#include <iostream>
class Data {
public:
Data() {
std::cout << "생성자" << std::endl;
}
Data(const Data&) {
std::cout << "복사 생성자" << std::endl;
}
Data(Data&&) noexcept {
std::cout << "이동 생성자" << std::endl;
}
};
Data prvalue() {
return Data(); // C++17 의무 생략: 플래그와 무관하게 "생성자"만
}
Data named() {
Data d;
return d; // NRVO: 플래그를 주면 "생성자" + "이동 생성자"
}
int main() {
auto a = prvalue();
auto b = named();
}
컴파일러가 실제로 생략했는지 확인하기
복사 생략이 성능에 주는 영향은 타입마다 다릅니다. std::vector의 이동은 포인터 세 개를 옮기는 정도라서, 생략되든 이동되든 비용 차이는 원소 버퍼를 할당하고 채우는 비용에 비하면 작습니다. 큰 차이가 나는 것은 생략도 이동도 안 되고 복사로 떨어지는 경우(위의 return flag ? a : b;처럼)이고, 그때는 버퍼 전체를 새로 할당해 복사합니다. 그래서 측정보다 먼저, 생성자에 로그를 넣어 실제로 어떤 생성자가 불리는지 확인하는 편이 원인을 찾기 쉽습니다.
#include <iostream>
class Tracker {
public:
Tracker() {
std::cout << "생성자" << std::endl;
}
Tracker(const Tracker&) {
std::cout << "복사 생성자" << std::endl;
}
Tracker(Tracker&&) noexcept {
std::cout << "이동 생성자" << std::endl;
}
~Tracker() {
std::cout << "소멸자" << std::endl;
}
};
Tracker func() {
Tracker t;
return t; // NRVO 대상
}
int main() {
std::cout << "=== 시작 ===" << std::endl;
auto t = func();
std::cout << "=== 끝 ===" << std::endl;
return 0;
}
출력 (기본 빌드, NRVO 적용):
=== 시작 ===
생성자
=== 끝 ===
소멸자
출력 (-fno-elide-constructors, NRVO 비활성화):
=== 시작 ===
생성자
이동 생성자
소멸자
=== 끝 ===
소멸자
컨테이너·팩토리·복잡한 객체 반환 예제
vector를 값으로 반환하기
#include <vector>
#include <string>
#include <iostream>
// NRVO 대상
std::vector<int> createVector(size_t size) {
std::vector<int> result(size);
for (size_t i = 0; i < size; i++) {
result[i] = static_cast<int>(i * i);
}
return result; // NRVO, 안 되면 이동
}
// 문자열 처리
std::string processString(const std::string& input) {
std::string result = input;
result += " processed";
return result; // NRVO, 안 되면 이동
}
int main() {
auto vec = createVector(10);
auto str = processString("Hello");
std::cout << "벡터 크기: " << vec.size() << std::endl;
std::cout << "문자열: " << str << std::endl;
for (size_t i = 0; i < vec.size(); ++i) {
std::cout << vec[i] << " ";
}
std::cout << std::endl;
return 0;
}
출력:
벡터 크기: 10
문자열: Hello processed
0 1 4 9 16 25 36 49 64 81
팩토리 함수에서 객체 생성
#include <iostream>
#include <string>
class Connection {
private:
std::string host;
int port;
public:
Connection(const std::string& h, int p)
: host(h), port(p) {
std::cout << "연결: " << host << ":" << port << std::endl;
}
Connection(const Connection& o) : host(o.host), port(o.port) {
std::cout << "복사 생성자" << std::endl;
}
Connection(Connection&& o) noexcept : host(std::move(o.host)), port(o.port) {
std::cout << "이동 생성자" << std::endl;
}
void info() const {
std::cout << "Connection(" << host << ":" << port << ")" << std::endl;
}
};
// 복사 생략
Connection createConnection(const std::string& host, int port) {
return Connection(host, port);
}
// 조건부 생성: 경로마다 prvalue를 반환하므로 모두 의무 생략
Connection createConnectionByType(const std::string& type) {
if (type == "local") {
return Connection("localhost", 8080);
} else if (type == "remote") {
return Connection("example.com", 443);
}
return Connection("default", 80);
}
int main() {
std::cout << "=== createConnection ===" << std::endl;
auto conn1 = createConnection("localhost", 8080);
std::cout << "\n=== createConnectionByType ===" << std::endl;
auto conn2 = createConnectionByType("remote");
std::cout << "\n=== 정보 출력 ===" << std::endl;
conn1.info();
conn2.info();
return 0;
}
출력:
=== createConnection ===
연결: localhost:8080
=== createConnectionByType ===
연결: example.com:443
=== 정보 출력 ===
Connection(localhost:8080)
Connection(example.com:443)
여러 멤버를 가진 객체 반환
#include <map>
#include <string>
#include <iostream>
class Config {
private:
std::map<std::string, std::string> settings;
public:
Config() {
std::cout << "Config 생성" << std::endl;
}
Config(const Config& o) : settings(o.settings) {
std::cout << "Config 복사" << std::endl;
}
Config(Config&& o) noexcept : settings(std::move(o.settings)) {
std::cout << "Config 이동" << std::endl;
}
void set(const std::string& key, const std::string& value) {
settings[key] = value;
}
std::string get(const std::string& key) const {
auto it = settings.find(key);
return it != settings.end() ? it->second : "";
}
};
// NRVO 대상 (보장은 아님)
Config loadConfig(const std::string& filename) {
Config config;
config.set("host", "localhost");
config.set("port", "8080");
config.set("debug", "true");
return config; // NRVO
}
int main() {
std::cout << "=== loadConfig ===" << std::endl;
auto config = loadConfig("app.conf");
std::cout << "\n=== 설정 출력 ===" << std::endl;
std::cout << "host: " << config.get("host") << std::endl;
std::cout << "port: " << config.get("port") << std::endl;
std::cout << "debug: " << config.get("debug") << std::endl;
return 0;
}
출력:
=== loadConfig ===
Config 생성
=== 설정 출력 ===
host: localhost
port: 8080
debug: true
값 반환·빌더·팩토리 패턴
출력 매개변수 대신 값으로 반환
#include <vector>
#include <string>
#include <iostream>
// ✅ 값 반환: NRVO 또는 이동이므로 원소 복사가 일어나지 않음
std::vector<int> loadData(const std::string& filename) {
std::vector<int> data;
// 파일에서 데이터 로드
for (int i = 0; i < 100; ++i) {
data.push_back(i * i);
}
return data; // NRVO, 안 되면 이동
}
int main() {
auto data = loadData("data.txt");
std::cout << "데이터 크기: " << data.size() << std::endl;
return 0;
}
빌더 패턴의 build() 반환
#include <string>
#include <iostream>
class QueryBuilder {
std::string query_;
public:
QueryBuilder& select(const std::string& fields) {
query_ = "SELECT " + fields;
return *this;
}
QueryBuilder& from(const std::string& table) {
query_ += " FROM " + table;
return *this;
}
QueryBuilder& where(const std::string& condition) {
query_ += " WHERE " + condition;
return *this;
}
std::string build() const {
return query_; // 멤버 반환은 NRVO 대상이 아니므로 복사
}
};
int main() {
auto query = QueryBuilder()
.select("*")
.from("users")
.where("age > 18")
.build();
std::cout << query << std::endl;
return 0;
}
출력:
SELECT * FROM users WHERE age > 18
build()는 멤버를 반환하므로 복사 생략이 적용되지 않습니다. 빌더를 재사용할 수 있게 하려면 이 복사가 의도한 동작이고, 빌더를 한 번만 쓴다면 std::string build() && { return std::move(query_); } 같은 rvalue 전용 오버로드로 복사를 이동으로 바꿀 수 있습니다. 다만 위 예제처럼 select()가 QueryBuilder&를 반환하는 체인에서는 결과가 lvalue라 && 오버로드가 선택되지 않으므로, 체인 메서드도 && 버전을 함께 만들어야 합니다.
연결 객체를 만드는 정적 팩토리 함수
#include <string>
#include <iostream>
class Connection {
std::string host_;
int port_;
public:
Connection(std::string host, int port)
: host_(std::move(host)), port_(port) {
std::cout << "Connection(" << host_ << ":" << port_ << ")" << std::endl;
}
void info() const {
std::cout << "연결: " << host_ << ":" << port_ << std::endl;
}
};
Connection createLocalConnection() {
return Connection("localhost", 8080); // 복사 생략
}
Connection createRemoteConnection(const std::string& host) {
return Connection(host, 443); // 복사 생략
}
int main() {
auto conn1 = createLocalConnection();
auto conn2 = createRemoteConnection("example.com");
conn1.info();
conn2.info();
return 0;
}
출력:
Connection(localhost:8080)
Connection(example.com:443)
연결: localhost:8080
연결: example.com:443
코드 리뷰에서 쓰는 복사 생략 규칙
아래는 “작동만 한다”가 아니라 코드 리뷰에서 반복되는 실무 규칙입니다.
반환값은 값으로, 단일 result 변수로 모으기
API는 출력 매개변수보다 값 반환을 우선 고려합니다. 복잡한 분기에서는 조기 반환 대신 T result;로 채운 뒤 마지막에 return result; 패턴이 NRVO에 유리한 경우가 많습니다. (단, 이는 보장이 아니라 관례적으로 유리한 형태입니다.)
std::optional·expected와의 조합
C++23 std::expected나 std::optional<T>를 반환하는 경우, 성공/실패 경로가 달라도 내부 T의 생성 위치를 단순하게 유지할수록 이동 횟수와 예외 경로가 줄어듭니다. “실패 시 빈 객체, 성공 시 채운 객체”를 한 블록에서 구성하도록 설계하는 편이 읽기 쉽습니다.
거대 객체·할당: “생략”만으로 끝나지 않는 경우
복사 생략은 복사/이동 호출을 없애도, 내부 버퍼 할당 자체를 없애지는 않습니다. 대용량 std::vector를 반환할 때는 예약(reserve)·재사용 가능한 버퍼·out-parameter로 재사용 같은 알고리즘 차원의 선택이 여전히 필요합니다.
컴파일러별 차이
NRVO 적용 여부는 컴파일러와 빌드 설정에 따라 다릅니다. 예를 들어 MSVC는 오랫동안 /O2 같은 최적화 빌드에서만 NRVO를 적용했고, Visual Studio 2022 17.4에서 /Zc:nrvo 옵션이 추가되어 /permissive-나 /std:c++20 이상 모드에서는 최적화를 끈 빌드에서도 NRVO를 적용합니다. 그래서 “이 함수는 반드시 NRVO된다”는 가정에 기대는 코드는 피하고, NRVO가 안 될 때도 이동으로 충분히 싸도록 타입을 설계하는 편이 안전합니다.
같이 보면 좋은 글
- noexcept로 C++ 이동 연산 최적화하기: 예외 계약 설계 패턴
- C++ 반환 방식 고르기: 값·레퍼런스·포인터 반환, RVO를 막는 return std::move
- C++ RVO·NRVO
- C++ Move Semantics
- C++ any
- C++ Branch Prediction
- C++ 캐시 최적화 실전 | 캐시 친화적 구조·프리페치·False Sharing·AoS vs SoA 가이드
- 모던 C++ (C++11~C++20) 핵심 문법 치트시트 | 현업에서 자주 쓰는 한눈에 보기