'값에 의한 전달'의 효율 문제 때문에 모든 코드를 '참조에 의한 전달'로 넘기는 것은 정말 좋지 않습니다. 유리수를 나타내는 클래스가 하나 있다고 가정합시다. 이 클래스에는 두 유리수를 곱하는 멤버 함수가 선언되어 있습니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 | class Rational { public: Rational(int numerator = 0, int denominator = 1); ... private: int n, d; // 분자 및 분모 friend const Rational operator*(const Rational& lhs, const Rational& rhs); }; | cs |
이 클래스의 operator*는 곱셈 결과를 값으로 반환하도록 선언되어 있습니다. 값이 아닌 참조자를 반환할 수 있으면 생성과 소멸에 들어가는 비용 부담은 확실히 없을 것입니다. 하지만 참조자는 존재하는 어떤 것에 붙는 '또 다른' 이름입니다. 따라서 이 함수가 참조자를 반환하도록 만들어졌다면, 이 함수가 반환하는 참조자는 반드시 이미 존재하는 Rational 객체의 참조자여야 합니다.
그럼, 반환된 객체는 어디에 있을까요? C++ 세상엔 '거저'가 없습니다. 그 유리수(객체)에 대한 참조자를 operator*에서 반환할 수 있으려면, 그 유리수 객체를 직접 생성해야 한다는 말입니다. 함수 수준에서 새로운 객체를 만드는 방법은 딱 두가지뿐입니다. 하나는 스택에 만드는 것이고, 또 하나는 힙에 만드는 것입니다.
먼저, 스택에 객체를 만들려면 지역 변수를 정의하면 됩니다.
1 2 3 4 5 6 | const Rational& operator*(const Rational& lhs, const Rational& rhs) { Rational result(lhs.n * rhs.n, lhs.d * rhs.d); return result; } | cs |
생성자가 불리는 게 싫어서 시작한 일인데, 결국 result가 다른 객체처럼 생성되어야 합니다. 더 심각한 문제는 연산자 함수는 result에 대한 참조자를 반환하는데, reult는 지역 객체입니다. 함수가 끝날 때 덩달아 소멸되기 때문에, 이 참조자가 가리키고 있는 대상은 전(前) Rational 객체입니다. 따라서 지역 객체에 대한 참조자를 반환하는 함수는 어떤 함수든지 미정의 동작이 발생하게 됩니다.
함수가 반환할 객체를 힙에 생성해 뒀다가 해당 객체의 참조자를 반환하는 것은 어떨까요?
1 2 3 4 5 6 | const Rational& operator*(const Rational& lhs, const Rational& rhs) { Rational *result = new Rational(lhs.n * rhs.n, lhs.d * rhs.d); return *result; } | cs |
new로 할당한 메모리를 초기화할 때 생성자가 호출되니 여전히 생성자가 한 번 호출됩니다. 여기서 new로 생성한 객체를 delete해줄 방법이 없기에, 메모리 누출이 발생합니다.
Rational 객체를 정적 객체로 함수 안에 정의해 놓고 이것의 참조자를 반환하는 식으로 operator*를 구성하는 방법을 떠올릴 수도 있습니다.
1 2 3 4 5 6 7 8 9 | const Rational& operator*(const Rational& lhs, const Rational& rhs) { static Rational result; // 반환할 참조자가 가리킬 정적 객체 result = ...; // lhs와 rhs를 곱하고 그 결과를 // result에 저장합니다. return result; } | cs |
정적 객체를 사용하는 설계가 항상 그러하듯, 이 코드 역시 스레드 안전성 문제가 얽혀 있습니다. 하지만 이보다 더 확실한 약점이 있는데 아래 코드를 보면 생각과 다르게 동작하는 부분이 있습니다.
1 2 3 4 5 6 7 8 9 10 11 12 | bool operator==(const Rational& lhs, const Rational& rhs); Rational a, b, c, d; ... if ((a * b) == (c * d)) { 두 유리수 쌍의 곱이 서로 같을 때의 처리; } else { 두 유리수 쌍의 곱이 서로 다를 때의 처리; } | cs |
이 코드에서 ((a * b) == (c * d)) 표현식은 a, b, c, d에 어떤 식이 들어가도 항상 true 값을 냅니다. operator==이 비교하는 피연산자는 operator* 안의 정적 Rational 객체의 값과 operator* 안의 정적 Rational 객체의 값으로, 두 값이 항상 같기 때문입니다.
새로운 객체를 반환해야 하는 함수를 작성하는 방법에는 정도(正道)가 있습니다. 바로 '새로운 객체를 반환하게 만드는 것'이죠. 그러니까 Rational의 operator*는 아래처럼 혹은 아래와 비슷하게 작성해야 합니다.
1 2 3 4 5 | inline const Rational operator*(const Rational& lhs, const Rational& rhs) { return Rational(lhs.n * rhs.n, lhs.d, rhs.d); } | cs |
이 코드에도 반환 값을 생성하고 소멸시키는 비용은 들어가 있습니다. 그러나 끝까지 따져 보면 여기에 들어가는 비용은 올바른 동작에 지불되는 작은 비용입니다. C++에서도 다 컴파일러 구현자들이 가시적인 동작 변경을 가하지 않고도 기존 코드의 수행 성능을 높이는 최적화를 적용할 수 있도록 배려해 두었습니다. 그 결과, 몇몇 조건하에서는 이 최적화 메커니즘(return value optimization, RVO)에 의해 operator*의 반환 값에 대한 생성과 소멸 동작이 안전하게 동작할 수 있습니다. 컴파일러가 이 기능을 갖고 있으면 위의 코드는 여전히 본래의 의도대로 동작할 것이며, 생각보다 빠릅니다.
참조자를 반환할 것인가 아니면 객체를 반환할 것인가를 결정할 때, 어떤 선택을 하든 올바른 동작이 이루어지도록 만들어야 합니다.
지역 스택 객체에 대한 포인터나 참조자를 반환하는 일, 혹은 힙에 할당된 객체에 대한 참조자를 반환하는 일, 또는 지역 정적 객체에 대한 포인터나 참조자를 반환하는 일은 그런 객체가 두 개 이상 필요해질 가능성이 있다면 절대로 하지 마세요.