Notice
Recent Posts
Recent Comments
Link
«   2026/05   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
Archives
Today
Total
관리 메뉴

로또

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

책/Effective C++

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

아롱로또 2023. 11. 19. 01:17

다루는 내용

객체 반환 시 참조자를 return 할 때 문제점을 알아보자.

 

값 반환

#include <iostream>
using namespace std;

// 유리수를 나타내는 클래스
class Rational{
public:
    Rational(int numerator = 0, int dominator = 1): n(numerator), d(dominator){}
    int n, d; // 분자, 분모

friend const Rational operator*(const Rational& lhs, const Rational& rhs){
        return Rational(lhs.n*rhs.n, lhs.d*rhs.d);
    }
};

int main(){
    Rational a(1, 2); // a = 1/2
    Rational b(3, 5); // b = 3/5
    Rational c = a * b; // c = 3 / 10
    cout << c.n << "/" << c.d << endl;
}

 

유리수를 나타내는 클래스 Rational의 곱셈 연산자를 보자. lhs, rhs의 분자, 분모를 각각 곱하여 새로운 Rational 객체를 생성하여 값을 반환하고 있다. 실행 결과 또한 다음과 같이 올바르게 잘 출력되는 것을 볼 수 있다.

실행 결과

참조자 반환

곱셈 연산자에서 값이 아닌, 참조자를 반환하도록 다음과 같이 함수를 수정해보자.

friend const Rational& operator*(const Rational& lhs, const Rational& rhs){
        Rational result(lhs.n*rhs.n, lhs.d*rhs.d);
        return result;
    }
};

이전 항목에서 보았듯, 참조자를 전달하는 것은 객체의 생성과 소멸에 드는 비용을 획기적으로 감소시킬 수 있지만. 이와 같이 코드를 수정한다면 큰 문제가 발생할 것이다.

 

참조자는 단지 이름이다. 존재하는 객체에 붙는 다른 이름에 불과하다.

함수가 참조자를 반환한다면, 해당 참조자는 반드시 이미 존재하는 Rational 객체의 참조자여야 한다.

operator*  함수에서 생성된 Rational 객체는 스택 영역에 생성될 것이다. 때문에 생성한 객체는 operator* 함수가 종료될 때 함께 소멸하게 된다. 결국 operator* 함수가 반환한 참조자는 이전에 Rational 객체였을 뿐, 더 이상 Rational 객체가 아니다. 해당 객체를 다른 곳에서 사용하게 된다면 미정의 동작을 일으킬 것이다.

 

그렇다면, 객체를 스택이 아닌 힙에 할당한다면 어떨까?

friend const Rational& operator*(const Rational& lhs, const Rational& rhs){
        Rational* result = new Rational(lhs.n*rhs.n, lhs.d*rhs.d);
        return *result;
    }
};
Rational w, x, y, z;
w = x * y * z; // operator*(operator*(x, y), z)

new를 해주었다면, 더 이상 해당 객체를 사용하지 않을 때 delete 하여 자원 누수를 막아야 할 것이다. 하지만 사용자는 operator* 로부터 반환되는 참조자 뒤에 숨겨진 포인터에 사용자가 접근할 방법이 없다.

 

정적 객체의 참조자의 경우에는 어떨까?

객체를 스택이든, 힙이든 어디에 할당하건 함수 호출 시마다 최소 1번의 생성자를 호출하여야 한다. 그렇다면 static keyword를 사용하여 정적 객체를 함수 안에 생성하고, 매 호출마다 해당 함수의 참조자를 반환하는 것은 어떨까?

friend const Rational& operator*(const Rational& lhs, const Rational& rhs){
        static Rational result;
        result.n = lhs.n * rhs.n;
        result.d = lhs.d * rhs.d;
        return result;
    }
};

 

이후, 다음과 같은 비교 연산자를 사용하여 두 유리수의 곱을 비교해보자.

 

bool operator== (const Rational& lhs, const Rational& rhs);

Rational a, b, c, d;
if(a*b == c*d){...}
else{...}

a, b, c, d가 어떤 값을 갖든 간에, a*b == c*d 는 참이다.

 

앞서 말했듯, 참조자는 단순히 다른 이름일 뿐이다. 로또를 스피또라 칭하건, 토토라 칭하건 로또는 로또이다.

즉, 위 코드는 동일한 객체에 대해서 서로 다른 이름으로 부르며 비교할 뿐이지, 그 본질은 동일한 객체이다.

 

새로운 객체를 만들어 반환하자.

const Rational operator*(const Rational& lhs, const Rational& rhs){
        return Rational(lhs.n*rhs.n, lhs.d*rhs.d);
}

 

가장 처음에 언급한 operator* 함수에서 반환 값을 생성하고 소멸시키는 비용은 분명히 존재한다. 그러나 이 비용은 올바른 동작을 위한 작은 비용일 뿐이다.

 

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

객체를 반환하든, 참조자를 반환하든 어떤 결정을 내리건 간에 항상 올바른 동작이 이루어지게 만들자. 

 

이것만은 잊지 말자!

  • 지역 스택 객체에 대한 포인터나 참조자를 반환하는 일, 혹은 힙에 할당된 객체에 대한 참조자를 반환하는 일, 도는 지역 정적 객체에 대한 포인터나 참조자를 반환하는 일은 그런 객체가 두 개 이상 필요해질 가능성이 있다면 절대로 하지 말자. (항목 4. "객체를 사용하기 전에 반드시 그 객체를 초기화하자." 에서 지역 정적 객체의 참조자를 반환하도록 설계된 코드를 찾을 수 있다.)