함수 호출 과정에서의 객체 수명 주기와 메서드 호출
사례 1: 객체 초기화, 복사, 그리고 임시 객체의 동작
C++에서 객체가 생성되고 복사되며 소멸되는 과정을 정확히 이해하는 것은 성능 최적화의 첫걸음입니다. 다음 코드는 다양한 초기화 및 대입 상황에서 어떤 생성자와 연산자가 호출되는지 보여줍니다.
#include <iostream>
class DataHolder {
public:
DataHolder(int val = 10) : value_(val) {
std::cout << "Constructor(int)\n";
}
~DataHolder() {
std::cout << "Destructor\n";
}
DataHolder(const DataHolder& other) : value_(other.value_) {
std::cout << "Copy Constructor\n";
}
DataHolder& operator=(const DataHolder& other) {
std::cout << "Assignment Operator\n";
value_ = other.value_;
return *this;
}
private:
int value_;
};
int main() {
DataHolder d1;
DataHolder d2(d1);
DataHolder d3 = d1;
DataHolder d4 = DataHolder(20);
d4 = d2;
d4 = DataHolder(30);
d4 = 40;
const DataHolder& ref = DataHolder(50);
return 0;
}
DataHolder d3 = d1;은 대입 연산자가 아닌 복사 생성자를 호출합니다. 변수를 정의하는 동시에 초기화할 때는 항상 복사 생성자가 사용됩니다.DataHolder d4 = DataHolder(20);에서DataHolder(20)은 임시 객체를 생성하는 것처럼 보이지만, 현대 C++ 컴파일러의 복사 생략(Copy Elision) 최적화에 의해 임시 객체 생성 없이d4가 직접 생성됩니다.d4 = d2;는 이미 존재하는 객체에 값을 덮어쓰므로 대입 연산자가 호출됩니다.d4 = DataHolder(30);은 명시적 임시 객체를 생성하여 대입한 후, 해당 문장이 끝나면 임시 객체를 즉시 소멸시킵니다. 반면d4 = 40;은 컴파일러가 적절한 생성자를 찾아 암시적 임시 객체를 생성한 뒤 대입합니다.const DataHolder& ref = DataHolder(50);에서 상수 왼쪽값 참조(Const Lvalue Reference)는 임시 객체의 수명을 참조 변수의 수명까지 연장시킵니다. 따라서 임시 객체는 문장이 끝난 후 소멸되지 않고main함수가 종료될 때까지 안전하게 유지됩니다.
사례 2: 전역 변수, 정적 변수 및 동적 할당에서의 객체 생성
객체의 저장 공간과 수명에 따라 생성 및 소멸 시점이 어떻게 달라지는지 확인해 봅니다.
#include <iostream>
class Metric {
public:
Metric(int x = 5, int y = 5) : x_(x), y_(y) {
std::cout << "Constructor(int, int)\n";
}
~Metric() {
std::cout << "Destructor\n";
}
Metric(const Metric& src) : x_(src.x_), y_(src.y_) {
std::cout << "Copy Constructor\n";
}
Metric& operator=(const Metric& src) {
x_ = src.x_;
y_ = src.y_;
std::cout << "Assignment Operator\n";
return *this;
}
private:
int x_;
int y_;
};
Metric global_m1(10, 10);
int main() {
Metric local_m(20, 20);
Metric copy_m = local_m;
static Metric static_m = Metric(30, 30);
local_m = Metric(40, 40);
local_m = 60;
Metric* ptr1 = new Metric(70, 70);
Metric* ptr2 = new Metric[2];
const Metric& ref_m = Metric(90, 90);
delete ptr1;
delete[] ptr2;
return 0;
}
Metric global_m2(100, 100);
- 전역 변수(
global_m1,global_m2)는main함수 진입 전에 정의된 순서대로 생성되며, 프로그램 종료 시 역순으로 소멸됩니다. - 정적 지역 변수(
static_m)는 최초 실행 시 한 번만 생성되며, 전역 변수와 마찬가지로 프로그램 종료 시 소멸됩니다.Metric(30, 30)형태의 초기화도 복사 생략 최적화가 적용되어 직접 생성됩니다. - 동적 할당(
new)된 객체는delete를 명시적으로 호출해야만 소멸자가 실행됩니다. 배열의 경우delete[]를 사용해야 모든 요소의 소멸자가 올바르게 호출됩니다.
사례 3: 함수 인자 전달과 반환 값 최적화(RVO/NRVO)
함수를 드나들 때 발생하는 객체의 복사 오버헤드는 C++ 성능 저하의 주요 원인 중 하나입니다.
#include <iostream>
class Entity {
public:
Entity(int data = 10) : data_(data) {
std::cout << "Constructor\n";
}
~Entity() {
std::cout << "Destructor\n";
}
Entity(const Entity& other) : data_(other.data_) {
std::cout << "Copy Constructor\n";
}
Entity& operator=(const Entity& other) {
std::cout << "Assignment Operator\n";
data_ = other.data_;
return *this;
}
int getData() const { return data_; }
private:
int data_;
};
Entity processEntity(Entity e) {
int val = e.getData();
Entity temp(val);
return temp;
}
int main() {
Entity e1;
Entity e2;
e2 = processEntity(e1);
return 0;
}
위의 코드에서 최적화가 적용되지 않는다고 가정할 때, 다음과 같은 불필요한 호출이 발생합니다.
e1이processEntity의 매개변수e로 값 전달되며 복사 생성자가 호출됩니다.- 함수 내부에서
temp객체가 생성자를 통해 만들어집니다. temp를 반환할 때, 호출자의 스택 프레임에 있는 익명의 반환 값 객체로 복사 생성자가 호출됩니다.- 지역 변수
temp와 매개변수e의 소멸자가 호출됩니다. - 반환된 익명 객체가
e2에 대입 연산자를 통해 할당된 후, 익명 객체의 소멸자가 호출됩니다.
현대 컴파일러는 반환 값 최적화(RVO) 및 명명된 반환 값 최적화(NRVO)를 통해 이러한 복사 과정을 생략하지만, 모든 환경에서 보장되지는 않습니다. 따라서 개발자가 직접 최적화 규칙을 준수하는 코드를 작성하는 것이 중요합니다.
객체 최적화를 위한 세 가지 핵심 규칙
규칙 1: 매개변수 전달 시 값 전달 대신 참조 전달 우선
함수의 매개변수로 객체를 받을 때는 값 전달(Pass-by-value) 대신 상수 참조(Const Reference)를 사용해야 합니다.
// 최적화 전: 불필요한 복사 생성자 및 소멸자 호출 발생
Entity processEntity(Entity e);
// 최적화 후: 복사 오버헤드 제거
Entity processEntity(const Entity& e);
참조 전달을 사용하면 매개변수를 위한 새로운 객체가 생성되지 않으므로, 복사 생성자와 소멸자 호출 오버헤드를 완전히 제거할 수 있습니다.
규칙 2: 객체 반환 시 지역 변수 대신 임시 객체 반환 우선
함수에서 객체를 반환할 때는 지역 변수를 반환하기보다 임시 객체(익명 객체)를 직접 생성하여 반환하는 것이 RVO(Return Value Optimization)를 유도하는 데 유리합니다.
Entity createEntity(int val) {
// 지역 변수를 반환하는 대신 임시 객체를 직접 반환
return Entity(val);
}
임시 객체를 반환하면 컴파일러가 반환 값 객체를 호출자의 메모리 공간에 직접 생성하도록 최적화할 수 있어, 추가적인 복사 생성을 방지할 수 있습니다.
규칙 3: 반환값 수신 시 대입 연산 대신 초기화 방식 우선
객체를 반환하는 함수의 결과를 받을 때는 이미 선언된 변수에 대입 연산자(=)를 사용하는 대신, 변수를 선언과 동시에 초기화해야 합니다.
int main() {
Entity e1;
// 대입 연산 사용: 대입 연산자 및 임시 객체 소멸 오버헤드 발생
Entity e2;
e2 = createEntity(20);
// 초기화 방식 사용: 복사 생략(Copy Elision)을 통한 직접 생성
Entity e3 = createEntity(30);
return 0;
}
초기화 방식을 사용하면 컴파일러가 반환된 임시 객체를 목표 변수(e3)의 메모리 위치에 직접 구성할 수 있습니다. 이를 통해 대입 연산자 호출과 임시 객체의 생성 및 소멸 과정을 생략하여 함수 호출 오버헤드를 최소화할 수 있습니다.