언어/C++

[Effective C++] 함수에서 객체를 반환해야 할 경우에 참조자를 반환하려고 들지 말자

지나가던 개발자 2020. 1. 21. 21:07
반응형

항목 21 : 함수에서 객체를 반환해야 할 경우에 참조자를 반환하려고 들지 말자

 

유리수를 나타내는 클래스가 하나 있다고 가정합시다. 이 클래스에는 두 유리수를 곱하는 멤버 함수가 선언되어 있습니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
class Rational {
public :
    Rational(int numerator = 0int denominator = 1);
 
...
 
private :
    int n, d;
 
friend
    const Rational operator*(const Rational& lhs, const Rational& rhs);
};
Colored by Color Scripter
 

 

여기서 operator*를 보면 이 함수가 참조자를 반환하도록 만들어졌다면, 이 함수가 반환하는 참조자는 반드시 이미 존재하는 Rational 객체의 참조자여야 합니다. 이 객체에는 곱셈이 가능한 두 객체의 곱이 들어 있어야 하는 것은 말할 필요도 없고요.

 

그럼 반환될 객체는 어디에 있을까요? 'operator* 호출 전에 어디엔가 생겼겠지'라고 생각하는 분은 혹시 없겠죠? 무슨 말인고 하니,

 

1
2
3
4
5
Rational a(12);            // a = 1/2
 
Rational b(35);            // b = 3/5
 
Rational c = a * b;            // c는 3/10이어야 합니다.
 

 

위의 코드에서 10분의 3이란 값을 가진 유리수가 이미 생겨 주지 않을까 하는 기대를 걸면 난감하다는 이야기입니다. C++ 세상엔 '거저'가 없습니다. 그 유리수(객체)에 대한 참조자를 operator*에서 반환할 수 있으려면, 그 유리수 객체를 직접 생성해야 한다는 말입니다.

 

함수 수준에서 새로운 객체를 만드는 방법은 딱 두 가지뿐입니다. 하나는 스택에 만드는 것이고, 또 하나는 힙에 만드는 것입니다.

 

스택에 객체를 만들려면 지역 변수를 정의하면 됩니다.

 

1
2
3
4
5
const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
    Rational result(lhs.n * rhs.n, lhs.d * rhs.d);
    return result;
}
Colored by Color Scripter
 

 

이런 방법은 피했으면 좋겠습니다. 생성자가 불리는 게 싫어서 시작한 일인데, 결국 result가 다른 객체처럼 생성되어야 합니다. 게다가 이 연산자 함수는 result에 대한 참조자를 반환하는데, result는 지역 객체입니다. 다시 말해 함수가 끝날 때 덩달아 소멸되는 객체입니다.

 

다음은 후자의 방법을 살펴볼 순서입니다. 함수가 반환할 객체를 힙에 생성해 뒀다가 그녀석의 참조자를 반환하는 것은 어떨까요?

 

1
2
3
4
5
const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
    Rational *result = new Rational(lhs.n * rhs.n, lhs.d * rhs.d);
    return result;
}
Colored by Color Scripter
 

 

하지만 여전히 생성자가 한 번 호출되기는 매한가지입니다. new로 할당한 메모리를 초기화할 때 생성자가 호출되니 말입니다. 그러나 이것 말고 다른 문제가 하나 더 있습니다. 여기서 new로 생성한 객체를 누가 delete로 뒤처리해 주길 바란다는 말입니까?

 

1
2
3
Rational w, x, y, z;
 
= x * y * z;                        // operator*(operator*(x, y), z)와 같습니다.
 

 

여기서는 한 문장 안에서 operator* 호출이 두 번 일어나고 있기 때문에, new에 짝을 맞추어 delete를 호출하는 작업도 두 번이 필요합니다. 그런데 operator*의 사용자 쪽에서는 이렇게 할 수 있는 합당한 방법이 없습니다. operator*로부터 반환되는 참조자 뒤에 숨겨진 포인터에 대해서는 사용자가 어떻게 접근할 방법이 없기 때문입니다.

 

Rational 객체를 정적 객체로 함수 안에 정의해 놓고 이것의 참조자를 반환하는 식으로 하면 어떨까요?

 

1
2
3
4
5
6
7
8
const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
    static Rational result;                                                // 반환할 참조자가 가리킬 정적 객체
 
    result = ...;                                                         // lhs와 rhs를 곱하고 그 결과를 result에 저장합니다.
 
    return result;
}
Colored by Color Scripter
 

 

정적 객체를 사용하는 설계가 항상 그러하듯, 이 코드 역시 스레드 안전성 문제가 얽혀 있습니다. 하지만 이보다 더 확실한 약점이 있는데, 아래에 준비한 멀쩡한 코드를 보며 고민해 봅시다

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bool operator==(const Rational& lhs, Rational& rhs);
 
Rational a, b, c, d;
 
...
 
if((a * b) == (c * d))
{
    // 두 유리수 쌍의 곱이 서로 같으면 적절한 처리를 수행합니다.
}
else
{
    // 다르면 적절한 처리를 수행합니다.
}
Colored by Color Scripter
 

 

((a * b) == (c * d)) 표현식이 항상 true 값을 냅니다.

 

operator== 함수가 호출될 때를 곰곰히 따져 봅시다. 이때 분명 두 개의 operator*함수 호출이 활성화되어 있을 것이고, 각각의 호출을 통해 operator* 안에 정의된 정적 Rational 객체의 참조자가 반환될 것입니다. operator==이 비교하는 피연산자는 operator* 안의 정적 Rational 객체의 값, 그리고 operator* 안의 정적 Rational 객체의 값입니다.

 

이정도면 operator* 등의 함수에서 참조자를 반환하는 것만큼 시간낭비가 없다는게 증명된 것 같습니다.

 

새로운 객체를 반환해야 하는 함수를 작성하는 방법에는 정도가 있습니다. 바로 '새로운 객체를 반환하게 만드는 것'이죠. 그러니까 Rational의 operator*는 아래처럼 혹은 아래와 비슷하게 작성해야 합니다.

 

1
2
3
4
inline const Rational operator*(const Rational& lhs, const Rational& rhs)
{
    return Rational(lhs.n * rhs.n, lhs.d * rhs.d);
}
Colored by Color Scripter
 

 

이 코드에도 반환 값을 생성하고 소멸시키는 비용이 들어 있지 않느냐는 분들이 있습니다.

맞습니다. 그러나 끝까지 따져 보면 여기에 들어가는 비용은 올바른 동작에 지불되는 작은 비용입니다. 모든 프로그래밍 언어가 그러하듯, C++에서도 다 컴파일러 구현자들이 가시적인 동작 변경을 가하지 않고도 기존 코드의 수행 성능을 높이는 최적화를 적용할 수 있도록 배려해 두었습니다. 그 결과, 몇몇 조건하에서는 이 최적화 메커니즘에 의해 operator*의 반환 값에 대한 생성과 소멸 동작이 안전하게 제거될 수 있습니다.

 

결론을 말하자면, 참조자를 반환할 것인가 아니면 객체를 반환할 것인가를 결정할 때, 이것만 잊지 말아주세요. 어떤 선택을 하든 올바른 동작이 이루어지도록 만드는 것! 이것이 진짜로 여러분이 할 일입니다.

 

꼭 잊지 말아야 할 것!

지역 스택 객체에 대한 포인터나 참조자를 반환하는 일, 혹은 힙에 할당된 객체에 대한 참조자를 반환하는 일, 또는 지역 정적 객체에 대한 포인터나 참조자를 반환하는 일은 그런 객체가 두 개 이상 필요해질 가능성이 있다면 절대로 하지마세요.

반응형