스토리텔링 개발자

[Effective Modern C++] 41. 항상 복사되는 매개변수는 값 전달도 고려하기 본문

Effective C++/Effective Modern C++

[Effective Modern C++] 41. 항상 복사되는 매개변수는 값 전달도 고려하기

김디트 2025. 4. 29. 11:15
728x90

항목 41. 이동이 저렴하고 항상 복사되는 복사 가능 매개변수에 대해서는 값 전달을 고려하라

 

 

 

복사되도록 만들어진 함수 매개변수
class Widget
{
public:
    // 이 버전은 복사되도록 만들어졌다.
    void addName(const std::String& newName) // lValue
    {
        names.push_back(newName); // 복사
    }
    
    // 이동 버전
    void addName(std::string&& newName) // rValue
    {
        names.push_back(std::move(newName)); // 이동
    }
    ...
    
private:
    std::vector<std::string> names;
};
  • 이 경우 본질적으로 같은 일을 하는 함수를 두 개 작성해야 한다.
    • 번거롭고, 유지보수에도 안 좋다.
    • 목적 코드(object code)에도 함수가 두 개 존재하게 된다.
      • 인라인화가 확실히 되지 않는다면 실행 프로그램의 크기가 커진다.
  • 대안 : addName 함수를 보편 참조(항목 24 참조)를 받는 함수 템플릿으로 만든다.
class Widget
{
public:
    template<typename T>
    void addName(T&& newName)
    {
        names.push_back(std::forward<T>(newName));
    }
    ...
};
  • 소스코드는 줄어들지만, 보편 참조를 사용하므로 그와 관련된 곤란한 문제점들이 발생할 수 있다.
  • 또한 템플릿이므로 반드시 헤더 파일에 두어야 한다.
    • 목적 코드에는 이 템플릿의 서로 다른 인스턴스가 여러 개 포함될 수 있다.
    • lValue, rValue에 대해 다르게 인스턴스화 될 뿐 아니라, std::string이나 이로 변환 가능한 타입들에 대해서도 다르게 인스턴스화 된다.(항목 25 참조)
  • 보편 함수로는 전달할 수 없는 인수 타입이 존재한다.(항목 30 참조)
  • 사용자 측에서 부적절한 타입의 인수를 전달하면 컴파일러가 난해한 오류 메시지를 출력한다.(항목 27 참조)

 

 

 

둘 모두를 충족시키는 법
  • 커스텀 타입 객체는 값으로 전달하지 말라고 배웠을 테지만...
    • 이 경우에는 그 규칙을 포기하도록 한다.
class Widget
{
public:
    void addName(std::string newName) // lValue나 rValue
    {
        names.push_back(std::move(NewName)); // 이동
    }
    ...
};
  • std::move를 사용한 이유?
    • newName은 호출하는 측에서 보낸 것과는 다른, 독립적인 객체이다.
    • 해당 위치가 newName을 마지막으로 사용하는 곳이므로 다른 대상으로 이동해도 된다.
  • addName 함수가 단 하나이므로 소스 코드와 목적 코드에서 코드 중복이 없다.
  • 헌데 값 전달은 비용이 크지 않나...?

 

 

 

C++11에서의 값 전달
  • C++98에서는 실제로 비용이 컸다.
    • 호출하는 측에서 뭘 넘겨주든, 매개변수 newName은 복사 생성에 의해 생성되었다.
  • 하지만 C++11에서는 인수가 lValue일 때만 복사 생성하고, rValue일 때는 이동 생성(move construction)한다.
Widget w;

...

std::string name("Bart");
w.addName(name); // lValue 호출. 복사 생성.

...

w.addName(name + "Jenne"); // rValue 호출. 이동 생성.

 

 

 

위에서 소개한 각 방법에 대한 고찰
  • 바로 위의 예제(w.addName())을 통해 확인해보자.
  • overload 구현
    • 호출측에서 넘겨준 인수가 lValue이든 rValue이든 인수는 newName에 묶인다.(비용이 없다.)
    • lValue 오버로드 함수에서는 newName이 Widget::names로 복사된다.
    • rVlaue 오버로드 함수에서는 newName이 Widget::names로 이동된다.
    • 비용 정리
      • lValue : 복사 1회
      • rValue : 이동 1회
  • 보편 참조 구현
    • 호출측에서 넘겨준 인수는 참조 newName에 묶인다.(비용이 없다.)
    • std::forward 때문에 lValue std::string 인수는 Widget::names에 복사된다.
    • std::forward 때문에 rValue std::string 인수는 Widget::names에 이동된다.
    • 비용 정리
      • lValue : 복사 1회
      • rValue : 이동 1회
    • 허나, 넘겨준 인수가 std::string 이외의 타입이면 그 인수는 std::string 생성자로 전달된다.
      • 그러면 std::string 복사 혹은 이동 연산이 0회 이상 일어날 수 있다.
      • 따라서 보편 참조를 받는 함수는 대단히 효율적일 수 있다.
      • 하지만 이번 분석과는 관련 없으므로 논의를 간단하게 하기 위해 항상 std::string을 인수로 받는다고 가정.
  • 값 전달 구현
    • 호출측에서 넘겨준 인수가 lValue이든 rValue이든 매개변수 newName이 반드시 생성된다.
      • 인수가 lValue면 복사 생성 1회
      • 인수가 rValue면 이동 생성 1회
    • 함수 본문에서는 어떤 경우든 newName이 항상 Widget::names로 이동된다.
    • 비용 정리
      • lValue : 복사 1회, 이동 1회
      • rValue : 이동 2회
    • 즉 참조 전달 접근방식들에 비해 이동이 하나 더 많다.

 

 

 

값 전달을 선택할 때 주의사항
  • 값 전달을 '사용하라'가 아니라 '고려하라'.
    • 함수를 하나만 작성하면 된다.
    • 목적 코드가 함수 하나만 만들어진다.
    • 보편 참조와 관련된 문제점이 없다.
    • 하지만 다른 대안들보다 비용이 크다.
  • 복사 가능 매개변수에 대해서만 고려해야 한다.
    • 복사할 수 없는 매개변수는 반드시 이동 전용 타입일 것이다.
    • 오버로드 방식에 비해 유리한 이유는 함수를 하나만 작성하면 된다는 부분이다.
    • 헌데 이동 전용 타입은 어차피 함수 하나만 작성하면 된다.
  • 이동이 저렴한 매개변수에 대해서만 고려해야 한다.
    • 이동 1회가 크리티컬하다면 이는 불필요한 복사를 수행하는 것이나 마찬가지이다.
  • 항상 복사되는 매개변수에 대해서만 고려해야 한다.
    • 만일 조건에 따라 복사를 행한다면 불필요한 생성, 파괴 비용을 유발한다.
    • class Widget
      {
      public:
          void addName(std::string newName)
          {
              // 조건에 따라 복사가 발생한다.
              if((newName.length() >= minLen) &&
                 (newName.length() <= maxLen))
              {
                  names.push_back(std::move(newName));
              }
              
              // 복사가 발생하지 않았다면, newName 생성, 파괴 비용만 나갈 뿐이다.
          }
          ...
      private:
          std::vector<std::string> names;
      };
  • 이동이 저렴한 복사 가능 타입이고 늘 복사를 수행하는 함수라 해도 값 전달이 적합하지 않은 경우가 있다.
    • 함수가 매개변수를 복사하는 방식이 두 가지이기 때문이다.
      • 생성을 통한 복사(복사 생성이나 이동 생성)
      • 할당을 통한 복사(복사 할당이나 이동 할당)
    • 앞선 예, addName은 생성을 사용한다.
    • 할당을 통해 복사하는 함수에서는 상황이 좀 더 복잡하다.
    • class Password
      {
      public:
          explicit Password(std::string pwd) // 값 전달
          : text(std::move(pwd)) {} // text를 생성
          
          void changeTo(std::string newPwd) // 값 전달
          {
              text = std::move(newPwd); // text를 할당
          }
          ...
      private:
          std::string text;
      };
      
      
      std::string initPwd("Supercalifragilisticexpialidocious");
      Password p(initPwd); // 값 전달 -> 생성을 통한 복사
      
      std::string newPassword = "Beware the Jabberwock";
      p.changeTo(newPassword); // 값 전달 -> 할당을 통한 복사
    • 할당으로 복사를 수행하면 비용이 아주 커질 수 있다.
    • 메모리 할당 문제
      • changeTo에 전달된 인수는 lValue이다.
      • 매개변수 newPwd가 생성될 때 호출되는 것은 std::string의 복사 생성자이다.
      • 그 생성자는 새 패스워드를 담을 메모리를 할당한다.
      • 이후 newPwd가 text로 이동 할당된다.
      • 그 시점에 text가 차지하고 있던 메모리가 해제된다.
      • 따라서 changeTo 안에서 동적 메모리 관리 동작이 두 번 발생한다.
        1. 새 패스워드를 담을 메모리 할당
        2. 기존 메모리가 차지하던 메모리를 해제
    • 헌데 지금 예에서는 기존 패스워드가 새 패스워드보다 길기 때문에 새롭게 메모리를 할당할 필요가 없다.
    • 오버로드 방식을 사용하면 메모리 할당 / 해제를 완전히 생략할 수 있다.
    • class Password
      {
      public:
          ...
          void changeTo(const std::string& newPwd) // lValue를 위한 버전
          {
              // text.capacity() >= newPwd.siz()라면 text 메모리를 재사용할 수 있다.
              text = newPwd;
          }
          ...
      private:
          std::string text;
      };
    • 물론 기존 패스워드가 새것보다 짧으면 할당 중 메모리 할당-해제를 피할 수는 없다.
      • 그 경우 값 전달과 참조 전달은 거의 비슷한 속도로 실행될 것이다.
      • 따라서 할당 기반 매개변수 복사의 비용은 할당에 관여하는 객체의 값에 의존한다.
    • 이런 비용 증가는 lValue 인수 전달시에만 적용된다.
      • 메모리 할당 해제는 진짜 복사 연산(이동 연산이 아닌!)이 수행될 때만 필요하다.
    • 즉, 최대한 빨라야 하는 소프트웨어에서는 값 전달이 그리 바람직하지 않다.
    • 더군다나 값 전달 함수에서 이동 연산이 몇 번일어나는지 확실하지 않은 경우도 있다.
      • Widget::addName 함수 내에서 또 다른 값 전달 방식 함수를 호출한다면?
  • 값 전달에서는 슬라이스 문제가 발생할 수 있다.
728x90
Comments