21: 함수에서 객체를 반환해야 할 경우에 참조자를 반환하려고 들지 말자
유리수를 나타내는 클래스가 하나 있다고 가정하자. 이 클래스에는 두 유리수를 곱하는 멤버 함수가 선언되어 있다.
class Rational {
public:
Rational(int numerator = 0, int denominator = 1);
private:
int n, d;
friend const Rational operator*(const Rational& lhs, const Rational& rhs);
};
이 클래스의 operator*는 곱셈 결과를 값으로 반환하고 있다. 여기서 Rational의 생성과 소멸에 들어가는 비용을 아끼기위해 참조자를 반환하도록 설계하면 어떤 문제가 발생할까?
참조자는 어떤 것에 대한 '또 다른' 이름이다. 함수가 참조자를 반환하도록 만들어졌다면, 반환하는 참조자는 반드시 이미 존재하는 Rational 객체의 참조자여야 한다. 그렇다면 Rational 객체를 직접 생성해야 한다.
함수 수준에서 새로운 객체를 만드는 방법은 딱 두 가지뿐이다. 하나는 스택에 만드는 것이고, 또 하나는 힙에 만드는 것이다. 스택에 객체를 만들려면 지역 변수를 정의하면 된다.
const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
Rational result(lhs.n & rhs.n, lhs.d * rhs.d);
return result.
}
생성자가 불리기 싫어서 한 일인데, 결국 result가 다른 객체처럼 생성되어야 한다. 그리고 result는 지역 객체이다. 다시 말해 함수가 끝날 때 덩달아 소멸되는 객체이다. 그러니까 현재 operator*는 온전한 Rational 객체에 대한 참조자를 반환하지 않는다. 단순히 메모리 뭉치를 반환할 뿐이다.
힙에 만드는 방법을 살펴보자. 힙 기반 객체는 new 연산자를 통해 생성된다.
const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
Rational result = new Rational(lhs.n * rhs.n, lhs.d * rhs.d);
return result;
}
여전히 생성자가 한 번 호출된다. 그리고 또 다른 문제가 있다. 여기서 new로 저질러 버린 객체를 대체 누가 delete로 뒤처리해 주는 것인가? 아무리 조심스러운 사람이라도 메모리 누출을 막을 수 없는 경우가 존재한다.
Rational w, x, y, z;
w = x * y * z; // operator*(operator*(x, y), z)와 같다.
여기서는 operator* 호출이 두 번 일어나고 있으니, delete를 호출하는 작업도 두 번 필요하다. 하지만 어떻게 접근할 방법이 없다.
여기서 생성자 호출을 피하려고 Rational 객체를 정적 객체로 함수 안에 정의해 놓고 이것의 참조자를 반환하는 식으로 operator*를 질러 버린다면 어떨까?
const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
static Ratinal result;
result = ...; // lhs와 rhs를 곱한 결과를 저장
return result;
}
bool operator==(const Rational& lhs, const Rational& rhs);
Ratinal a, b, c, d;
if ((a * b) == (c * d))
{}
else
{}
이제는 스레드 안정성 문제가 얽혀 있다. 위의 ((a * b) == (c * d)) 표현식이 항상 true 값을 낸다. a, b, c, d에 어떤 값을 넣어도 마찬가지다. 정적 객체이므로 당연한 결과이다.
여기서 정적 배열을 쓰면 문제를 해결할 수 있다고 생각하는 것도 말이 안 된다. 그럼 배열의 크기 n을 정해야 할 것이다. n의 크기가 너무 작으면 반환 값을 저장할 공간이 부족해질 것이고, n이 너무 크면 프로그램의 수행 성능이 떨어진다. 그리고 함수가 가장 처음 실행될 때 배열 안의 모든 객체가 '생성'된다. 비록 딱 한 번뿐이지만, 이때 생성자가 n번 호출되고 유효범위가 끝날 때 소멸자가 n번 호출된다. 배열 안의 객체에 값을 어떻게 넣을 것이며, 비용이 얼마나 들지도 모른다. 그리고 대입에 어떤 비용이 들어갈 것이며, 소멸자와 생성자가 한 번씩 호출되어야 한다.
새로운 객체를 반환해야 하는 함수를 작성하는 방법에는 정도가 있다. 바로 '새로운 객체를 반환하게 만드는 것'이다. 그러니까 Rational의 operator*는 아래처럼 혹은 아래와 비슷하게 작성해야 한다.
inline const Rational operator*(const Rational& lhs, const Rational& rhs)
{
return Rational(lhs.n * rhs.n, lhs.d * rhs.d);
}
물론 생성하고 소멸시키는 비용은 들어있다. 하지만 여기에 들어가는 비용은 올바른 동작에 지불되는 작은 비용이다. 컴파일러 구현자들이 가시적인 동작 변경을 가하지 않고도 기존 코드의 수행 성능을 높이는 최적화를 적용할 수 있도록 배려해 두었다.
이것만은 잊지 말자!
- 지역 스택 객체에 대한 포인터나 참조자를 반환하는 일, 혹은 힙에 할당된 객체에 대한 참조자를 반환하는 일, 또는 지역 정적 객체에 대한 포인터나 참조자를 반환하는 일은 그런 객체가 두 개 이상 필요해질 가능성이 있다면 절대로 하지말자.
'C++ > Effective C++' 카테고리의 다른 글
[Effective C++] 23: 멤버 함수보다는 비멤버 비프렌드 함수와 더 가까워지자 (1) | 2023.10.20 |
---|---|
[Effective C++] 22: 데이터 멤버가 선언될 곳은 private 영역임을 명심하자 (0) | 2023.10.19 |
[Effective C++] 20: '값에 의한 전달'보다는 '상수객체 참조자에 의한 전달' 방식을 택하는 편이 대개 낫다 (0) | 2023.10.17 |
[Effective C++] 19: 클래스 설계는 타입 설계와 똑같이 취급하자 (0) | 2023.10.16 |
[Effective C++] 18: 인터페이스 설계는 제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게 하자 (0) | 2023.10.15 |