상세 컨텐츠

본문 제목

항목 20) 값 전달 보다는 상수객체 참조자 전달 방식을 고려하자.

C++/Effective C++

by DeaKyungLee 2022. 8. 27. 16:56

본문

 

Call by Value 

 

기본적으로 C++ 언어는,
객체에 대해서 '값에 의한 전달 ( Call by value )' 방식을 사용한다. ( C 에서 물려받은 특성 중 하나 )
return 형식이 객체거나 파라미터 전달에 객체를 사용한다면 기본적으로 사본이 생성된다는 의미이다.

우리는 객체의 복사 연산이 의도치 않게 굉장히 고비용의 연산이 될 수 있다는 사실을 알고 있다.
아래의 예시에서 해당 내용을 다시 한 번 확인해보자.

class Person{
public:
    Person();
    virtual ~Person();      // 가상 소멸자, 항목 7
private:
    std::string name;
    std::string address;
};
  
class Student :public Person{
public:
    Student();
    ~Student();
private:
    std::string schoolName;
    std::string schoolAdddress;
};
 
bool validateStudent(Student s); // 값에 의한 전달
 
 
 
 
int main()
{  
    Student plato;
    bool platoIsOk = validateStudent(plato);
     
    return 0;
}

먼저 validateStudent 함수 파라미터는 Student 객체를 받는데, 여기서 복사가 일어난다. ( 1 복사 생성자 call )
그리고 validateStudent 함수가 끝날 때, 파라미터로 전달된 객체 s 역시 함께 소멸한다. ( 1 소멸자 call )

 

그런데 Student 객체 자체가 string 객체 두 개를 멤버로 가진다. ( 3 복사 생성자 call )
거기에 Student 객체는 Person 객체에서 파생되었기 때문에
Person 객체가 먼저 생성 되고, ( 4 복사 생성자 call )  Person 객체의 두 string 멤버가 생성된다. ( 6 복사 생성자 call )
그리고 물론 생성된 횟수 만큼 소멸자도 호출 된다. ( 6 소멸자 call )

 

정리하자면,
단순히 "validateStudent(plato); " 이 문장에서 파라미터 전달 행위 한 번에 6번의 생성자 및 소멸자 호출이라는 비용을 지불하게 된다.
이런 상황을 의도했다면 모를까, 아니라면 굉장히 비효율적인 상황이다.
( 해당 함수의 저러한 상황을 모르고 다른 프로그래머가 validateStudent 활용하는 다른 함수를 구성한다면 비효율은 걷잡을 수 없게 커진다. 해당 상황을 나중에 발견하여 수정하더라도 어떤 Side Effect가 생길지는 아무도 장담할 수 없다. )

 

이러한 코드를 바람직하게 바꾸는 방법은 '상수 객체에 대한 참조자 ( reference to const ) ' 로 전달하게 바꾸는 것이다.

bool validateStudent(const Student& s);     // Student const& s 라고 표기해도 문제없다. ( 항목 3 )

 

참조자 형식으로 전달하면서 불필요한 생성자 및 소멸자 호출을 막고,
const 키워드를 통해서 원본 객체값이 변경되는 경우를 방지할 수 있다.
그리고 또 하나의 장점이 있는데, 복사 손실 문제( Slicing Problem ) 가 사라진다는 것이다.

 

Slicing Problem

class Window{
public:
    std::string name() const;
    virtual void display() const;
};
  
class WindowWithScrollBars :public Window{
public:
    virtual void display() const;
};
  
// 값에 의한 전달 → 복사손실 발생
void printNameAndDisplay(Window w)
{
    std::cout << w.name();
    w.display();
}
  
WindowWithScrollBars wwsb;
printNameAndDisplay(wwsb);

 

위의 상황을 살펴 보자.
Window 객체를 상속 받는 WindowWithScrollBars 객체가 printNameAndDisplay 함수의 파라미터로 전달되고 있다.
여기서 값 복사가 일어나기 때문에 WindowWithScrollBars  객체가 가지고 있는 고유의 정보는 전부 잘려나간다.
때문에 WindowWithScrollBars  객체의 display 함수는 영원히 호출되지 않는다.

// 참조에 의한 전달 -> 복사 손실 x
void printNameAndDisplay(const Window& w)
{
    std::cout << w.name();
    w.display();
}


해당 함수의 매개변수 형식을 const 참조자로 바꾸면 복사 손실 문제가 발생하지 않는다.
어떤 Window가 전달되더라도 해당 객체 고유의 성질을 그대로 가지게 된다.

 

예외 사항

 

앞서 '값에 의한 전달' 보다는 '상수 객체 참조자에 의한 전달' 방식이 더 좋다고만 설명했다.
하지만 '값에 의한 전달'을 해도 별로 차이가 없거나 오히려 더 좋은 경우가 있는데,
기본 제공 타입 (built-in type: int, double... ), iterator, Functor ( 함수 객체 ) 에 대해서는 값에 의한 전달을 선택해도 문제 없다.

더보기
  •  () 연산자를 재정의해서 함수처럼 사용 가능한 객체
  • "Function Object" 또는 "Functor" 라고도 부른다.
struct Functor {
    void operator()() {
        cout << "Functor!" << endl;
    }
}
 
void main() {
    Functor functor;
    functor(); // 해석하면 operator()()이란 '멤버 함수'를 호출합니다.
}

객체이면서 함수처럼 사용되는데, Server-Client 간의 Callback 호출 상황에서 유용하게 사용된다.
말했다시피 객체이기 때문에 고유의 상태값을 가지면서 콜백으로 활용될 수 있기 때문이다.

참고 링크
https://pangtrue.tistory.com/19

 

iterator, Functor 의 경우에는 예전부터 값으로 전달되도록 설계해 왔기 때문인데,
그렇기 때문에 반복자와 함수 객체를 구현할 때는 반드시 복사 효율 복사 손실 방지가 필수적이다.

그리고 기본 제공 타입( built-in type )은 결론적으로 크기가 작기 때문이라고 할 수 있는데,
그렇다고 "크기가 작은 타입은 모두 값에 의한 전달을 해도 된다" 는 의미로 해석해서는 안 된다.
하나의 예시로 1. 그냥 double 타입과 2. double 하나로만 이루어진 struct 를 비교해보자.
대부분의 컴파일러는 1의 경우에 레지스터에 넣어 주지만, 2의 경우는 그렇지 않다.

생각해보면 이유는 간단하다.
사용자 정의 객체로 이루어진 경우 언제라도 크기가 더 커질 수 있기 때문이다.
지금 당장은 double 하나를 가지는 객체라도 미래에는 크기가 얼마나 커질지 아무도 모른다.

 

Things to Remember

  • '값에 의한 전달' 보다는 '참조에 의한 전달'을 선호하자. 효율만이 아니라 복사 손실 문제까지 방지 해준다.
  • 하지만 기본 제공 타입, 반복자, 함수 객체 타입에는 '값에 의한 전달' 방식이 더 적절하다.

 

관련글 더보기

댓글 영역