20: '값에 의한 전달'보다는 '상수객체 참조자에 의한 전달' 방식을 택하는 편이 대개 낫다
C++는 함수로부터 객체를 전달받거나 함수에 객체를 전달할 때 '값에 의한 전달(pass-by-value)' 방식을 사용한다. 특별히 다른 방식을 지정하지 않는 한, 함수 매개변수는 실제 인자의 '사본'을 통해 초기화된다. 이들 사본을 만들어내는 원천이 바로 복사 생성자인데, 이 점 때문에 '값에 의한 전달'은 고비용의 연산이 되기도 한다.
class Person {
public:
Person();
virtual ~Person();
private:
string name;
string address;
};
class Student : public Person
{
public:
Student();
~Student();
private:
string SchoolName;
string SchoolAddress;
};
bool validateStudent(Student s);
Student plato;
bool platoIsOk = validateStudent(plato);
plato로 부터 매개변수 s를 초기화시키기 위해 Student의 복사 생성자가 호출될 것이다. 게다가 s는 validateStudent가 복귀할 때 소멸될 것이다. 정리하면 validateStudent의 매개변수 전달 비용은 Student의 복사 생성자 호출 한 번, 그리고 Student의 소멸자 호출 한 번이다.
이렇게 끝나면 큰 끼쁨이겠지만 끝나려면 아직 멀었다. Student 객체가 생성될 때마다 string 객체 두 개가 덩달아 생성되어야 한다. 게다가 Student 객체는 Person 객체로부터 파생되었기에 Student 객체가 생성되면 Person 객체도 (먼저) 생성되어야 한다. Person 객체도 매번 생성될 때 string 생성자가 두 번 더 불려야 한다. 최종 적으로 Student 복사 생성자 호출 한 번, Person 복사 생성자 한 번, 추가로 string 복사 생성자 호출이 네 번 일어난다. 소멸도 암담하다. 앞에서 호출된 생성자들 각각이 소멸자 호출과 대응된다. 총 비용을 계산해 보면 생성자 여섯 번에 소멸자 여섯 번이다.
상수객체에 대한 참조자(reference-to-const)로 전달하면 좋아진다.
bool validateStudent(const Student& s);
이렇게 하면 순식간에 훨씬 효율적인 코드로 바뀐다. 새로 만들어지는 객체 같은 것이 없기 때문에, 생성자와 소멸자가 전혀 호출되지 않기 때문이다. 여기서 새겨둬야 할 점은 매개변수 선언문에 있는 const이다. 이것이 붙지 않으면 validateStudent함수로 넘어간 Student 객체가 변할지도 모른다는 걱정을 호출부가 해야 한다.
참조에 의한 전달 방식으로 매개변수를 넘기면 복사손실 문제(slicing problem)가 없어지는 장점도 있다. 파생 클래스 객체가 기본 클래스 객체로서 전달되는 경우, 이 객체가 값으로 전달되면 기본 클래스의 복사 생성자가 호출되고, 파생 클래스 객체로 동작하게 해 주는 특징들이 잘려 떨어지고 만다.
class Window {
public:
string name() const;
virtual void display() const;
};
class WindowWithScrollBars : public Window {
public:
virtual void display() const;
};
이 윈도우를 사용하는 함수를 하나 만들어 보겠다.
void printNameAndDisplay(Window w)
{
cout << w.name();
w.display();
}
WindowWithScrollBars wwsb;
printNameAndDisplay(wwsb);
매개변수 w가 생성되기는 하는데 Window 객체로 만들어지면서 wwsb가 WindowWithScrollBars 객체의 구실을 할 수 있는 부속 정보가 썰려 나간다. w는 어떤 타입 객체가 넘겨지든 아랑곳없이 Window 클래스 객체의 면모만을 갖게 된다. WindowWithScrollBars::display는 영원히 호출되지 않는다.
이 문제에서 도망가려면, w를 상수객체에 대한 참조자로 전달하도록 만들면 된다.
void printNameAndDisplay(const Window& w)
{
cout << w.name();
w.display();
}
이제 w는 어떤 종류의 윈도우가 넘겨지더라도 그 윈도우의 성질을 그대로 갖게 된다.
참조자는 보통 포인터를 써서 구현된다. 즉 참조자를 전달한다는 것은 결국 포인터를 전달한다는 것과 일맥상통한다. 이렇게 따져 보면, 전달하는 객체의 타입이 기본제공 타입(int 등)일 경우에는 참조자로 넘기는 것보다 값으로 넘기는 편이 더 효율적일 때가 많다. STL의 반복자와 함수 객체에도 마찬가지이다. 예전부터 반복자와 함수 객체는 값으로 전달되도록 설계해 왔기 때문이다.
타입 크기만 작으면 전부 '값에 의한 전달'을 할 수 있다고 생각하는 것은 잘못된 생각이다. 데이터 멤버가 포인터 하나뿐인 객체인 경우를 생각해보자. 포인터 하나뿐인 객체라도 그 포인터 멤버가 가리키는 대상까지 복사하는 작업도 따라다녀야 한다.
그럼 객체가 크기가 작고 복사 생성자도 그다지 비싸지 않게 만들어졌다고 가정하자. 그래도 수행 성능 문제가 발목을 잡을 수 있다. 컴파일러 중에는 기본제공 타입과 사용자 정의 타입을 아예 다르게 취급하는 것들이 있다. 기본제공 타입과 사용자 정의 타입의 하부 표현구조가 같아도 말이다. 이를테면 진짜 double은 레지스터에 넣어 주지만, double하나로만 만들어진 객체는 레지스터에 넣지 않는 것이다.
크기가 작다고 해서 값으로 전달할 수 없는 이유가 하나 더 있다. 사용자 정의 타입의 크기는 언제든 변화에 노출되어 있다는 것이다. 지금은 작아도 나중에는 커질지도 모른다.
일반적으로, '값에 의한 전달'이 저비용이라고 가정해도 괜찮은 유일한 타입은 기본제공 타입, STL 반복자, 함수 객체 타입, 이렇게 세 가지뿐이다. 이 외의 타입에 대해서는 '상수객체 참조자에 의한 전달'을 선택하라.
이것만은 잊지 말자!
- '값에 의한 전달'보다는 '상수 객체 참조자에 의한 전달'을 선호하자. 대체적으로 효율적일 뿐만 아니라 복사손실 문제까지 막아준다.
- 이번 항목에서 다룬 법칙은 기본제공 타입 및 STL 반복자, 그리고 함수 객체 타입에는 맞지 않는다. 이들에 대해서는 '값에 의한 전달'이 더 적절하다.
'C++ > Effective C++' 카테고리의 다른 글
[Effective C++] 22: 데이터 멤버가 선언될 곳은 private 영역임을 명심하자 (0) | 2023.10.19 |
---|---|
[Effective C++] 21: 함수에서 객체를 반환해야 할 경우에 참조자를 반환하려고 들지 말자 (1) | 2023.10.18 |
[Effective C++] 19: 클래스 설계는 타입 설계와 똑같이 취급하자 (0) | 2023.10.16 |
[Effective C++] 18: 인터페이스 설계는 제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게 하자 (0) | 2023.10.15 |
[Effective C++] 17: new로 생성한 객체를 스마트 포인터에 저장하는 코드는 별도의 한 문장으로 만들자 (0) | 2023.10.14 |