C++에서 const Klass&반환값 형태의 단점들.

Tags:

논의하고 싶은 상황은 예를들면 아래와 같은 경우입니다.

class Foo {
public:
 const Klass& foo() {
    return ...;
 }
};

foo()는 Klass을 반환해야할까요 아니면 const Klass&을 반환해야할까요? const Klass& 형태의 리턴을 원하는 까닭은 당연하게도 퍼포먼스입니다. 그러나 Klass을 반환해야하는 이유는 더 많습니다.

const reference가 아니라 value를 반환해야하는 이유

  • 만약 foo()의 구현이 내부에서 복잡한 연산을 한다음 Klass를 리턴하는 것이라면 Klass 복사하는 것을 없애는 것이 전체 실행시간에 큰 영향이 없습니다. 특히 Klass가 string같은 단순 문자열 복사라면 더 그렇습니다. 퍼포먼스 튜닝은 1) 성능 목표 결정, 2) 컴포넌트별 퍼포먼스 측정, 3) 최적화의 3단계로 이루어져야지 이처럼 단순히 복사 생성자 하나만 무작정 잡고 보는 것은 아래에 또다시 설명할 이유들로 인해 premature optimization이 되고 맙니다.
  • 만약 foo()가 멤버 변수를 리턴하는 함수라면 Foo 클래스 인스턴스가 메모리에서 없어진 후에 그 반환값을 사용할 수 없습니다. 예를 들어 다음 경우.
    #include <iostream>
    
    using namespace std;
    
    class Foo {
     public:
      Foo(): val_("hi") {
      }
      string val_;
      const string& GetVal() const {
        return val_;
      }
    };
    
    int main() {
      Foo* foo = new Foo;
      const string& val = foo->GetVal();
      delete foo;
      cout << val << endl;  // dangling reference!
      return 0;
    }
    

    val 은 dangling reference입니다. 모든 코드가 간단하면야 이런 뻔한 실수를 하겠습니까만, 코드가 길어지면 모르는 일이죠.

  • 내부의 구현을 반환하는 것은 어쨌거나 내부의 구현을 노출합니다! 단순히 추상적 아름다움을 깰뿐만 아니라 심지어 Security에 문제가 됩니다. 예를들어 다음과 같은 Bar, Foo클래스를 사용자에게 제공했다고 가정해보겠습니다.
    #include<iostream>
    
    using namespace std;
    
    class Bar {
     public:
      Bar(int v) {
        val_ = v;
      }
      int GetVal() const {
        return val_;
      }
      void SetVal(int v) {
        val_ = v;
      }
     private:
      int val_;
    };
    
    class Foo {
     public:
      Foo(): bar_(1) {
      }
      const Bar& GetBar() const {
        return bar_;
      }
     private:
      Bar bar_;
    };
    

    이 클래스를 제공하는 자는 이 클래스를 가져다 쓰는 클라이언트가 Foo를 만들고 GetBar()로 Bar를 얻을땐 항상 Bar내의 val_ 값이 1이기를 기대하겠지만, 다음과 같이 그 기대는 쉽게 깨집니다.

    int main() {
      Foo* foo = new Foo;
      Bar& bar = const_cast<Bar&>(foo->GetBar());
      cout << bar.GetVal() << endl;  // 1
      cout << foo->GetBar().GetVal() << endl;  // 1
      bar.SetVal(2);  
      cout << bar.GetVal() << endl;  // 2
      cout << foo->GetBar().GetVal() << endl;  // 2
      return 0;
    }
    

    이 예에서 보듯이 클라이언트가 손쉽게 Foo내 bar에 대한 레퍼런스를 획득했습니다.

  • const string&을 반환하는 라이브러리를 만들어도 사용자가 순순히 최적화에 응해주지 않습니다.
    #include <iostream>
    
    using namespace std;
    
    class Foo {
     public:
      Foo(): val_("hi") {
      }
      string val_;
      const string& GetVal() const {
        return val_;
      }
    };
    
    int main() {
      Foo* foo = new Foo;
      string val = foo->GetVal();  // const string&가 아니라도 아무 문제 없이 컴파일.
      cout << val << endl;
      return 0;
    }
    

    이렇게 되면 피했다고 생각하는 문자열 복사가 일어나고 맙니다.

  • 만약 어느날 갑자기 Foo가 멀티 쓰레딩으로 전환된다면 생각지도 못한 버그들이 생겨납니다.
    #include <iostream>
    
    using namespace std;
    
    class Foo {
     public:
      Foo(): val_("hi") {
      }
      string val_;
      const string& GetVal() const {
        return val_;
      }
      void SetVal(const string& v) {
        val_ = v;
      }
    };
    
    ...
    
    Foo* foo = new Foo;
    ... Thread1과 Thread2를 시작시킴 ...
    
    // Thread 1이 실행됨
    const string& val = foo->GetVal(); <- 이 시점에 Thread1은 val == "hi"를 기대
    
    // Thread 2 가 들어오고 다음 라인을 실행
    foo->SetVal("hello");
    
    // Thread 1이 다시 살아나서 다음 라인을 실행
    cout << val << endl;  <- 예상과 달리 hello가 출력!
    

    Thread1이 GetVal()가 순수한 getter라고 생각했다면 Thread1입장에선 정말로 황당한 일이 될 뿐입니다.

  • 어떤 경우엔 const Klass&가 Klass를 바로 리턴하는것 대비 이득이 없을수 있습니다. 예를들어 다음코드는 return 문에서 1개의 temporary object를 생성하고 이것을 그대로 caller쪽의 f에 가져다 줍니다.
    const Foo& computeFoo() {
      .. 작업 ..
      return Foo(...);
    }
    
    ...
    
    const Foo& f = computeFoo();
    

    그런데 그냥 string을 리턴하더라도 똑같은 최적화가 RVO, NRVO에서 이미 이루어집니다. 물론 이것이 만병통치약으로 적용되는 것은 아닙니다만 최소한 위와 같은 포멧에서는 적용되는 것으로 알고 있습니다.

예외적으로 const reference반환이 가능한 경우
이렇게 놓고 볼때, 어떤 경우에 const Klass&를 사용할 것인가에 대한 답은 다음의 경우에 한정됩니다.

  1. Caller측 코드도 내가 다 보고 있어서 반환값을 const reference로 받는 것이 보장되고, Caller가 그 반환값을 Callee가 파괴되기전에 사용 완료하는 것이 보장되며,
  2. 성능이 매우 중요한 상황인데 Klass복사가 너무 비싸거나 값싸더라도 그 복사가 너무 자주 일어나고,
  3. 성능 목표상 이러한 수정이 확실히 필요한 경우일때.

그러나 실제로는 이런 중대한 일이 벌어지는 것을 알려주는 코딩 포멧은 const Klass& 보다는 다음과 같은 형태입니다.

// 너무 암시적이고 client가 손쉽게 무시가능함.
// 순진하게 아무것도 모르고 BigData bd = foo(); 라고 호출해버릴 수 있음.
const BigData& foo() { 
  ...
  return big_data;
}

// 도저히 모르고 지나칠 수 없는 인터페이스.
void foo(BigData** big_data) {  
  ...
  **big_data = ...;
}

두번째 형태의 포멧을 보면 foo()의 클라이언트 입장에서는 뭔가 심상치 않은 일이 벌어짐을 직감하게 됩니다. 무슨일인가 들여다보게되고, foo 함수의 설명도 읽고, 제대로 데이터를 갖다 쓰게 됩니다.

Reference:
1. pros and cons of returning const ref to string instead of string by value
2. http://en.wikipedia.org/wiki/Return_value_optimization
3. http://stackoverflow.com/questions/134731/returning-a-const-reference-to-an-object-instead-of-a-copy
4. http://www.parashift.com/c++-faq-lite/return-by-value-optimization.html

Comments

3 responses to “C++에서 const Klass&반환값 형태의 단점들.”

  1. JM Avatar
    JM

    앞으로는 C++11의 move 시맨틱을 쓰면 둘다 해결되지 않을까요? @_@

  2. Minkoo Seo Avatar
    Minkoo Seo

    Move semantics를 쓰려면 반환값이 const여서는 안되는 것으로 알아요. Foo f = foo() 형태로 호출이 되었을때 foo()가 반환하는 값의 내부 메모리를 f 에 복사해주는 것이 move semantics이죠. 그런데 이렇게 복사해올때 temporary object내부의 메모리를 지워줘야하거든요.

    http://www.cprogramming.com/c++11/rvalue-references-and-move-semantics-in-c++11.html 에서 ‘move constructor’를 찾아보시면 중간쯤에 있는데요. 인용하자면 이렇습니다.

        // move constructor
        ArrayWrapper (ArrayWrapper&& other)
            : _p_vals( other._p_vals  )
            , _size( other._size )
        {
            other._p_vals = NULL;
        }
     

    이처럼 other 내부의 메모리를 건드려야하므로 other는 const가 아니어야합니다.

    move semantics는 이처럼 const 를 반환하지 말아야하는 또하나의 이유로 생각하고 있습니다. 그런데 제 컴의 g++이 옛날거라 아직 좋은 예제를 만들거나 테스트를 많이 해보지는 못했습니다…

  3. […] C++에서 const Klass&반환값 형태의 단점들을 쓴지도 시간이 많이 지났네요. C++11에서는 많은 것이 바뀌었습니다. 대표적인 것이 rvalue reference로 대표되는 Move semantics입니다. Move는 RVO(return value optimization)가 동작할 수 없을 때 객체의 복사비용을 줄이는 목적으로 사용됩니다. Move는 객체를 “복사”하는 대신 객체가 내부에 가진 포인터만 가져옵니다. 그런이유로 속도가 매우 빠릅니다. […]

Leave a Reply

Your email address will not be published. Required fields are marked *