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

게시:     수정

카테고리:

태그: ,

이 글은 아래의 책을 정리하였습니다.
이펙티브 모던 C++, 스콧 마이어스 저자, 류광 번역

가독성이 떨어지는 직역들을 수정하며 정리하였습니다.
e.g. 연역 → 추론, 중복적재 → 오버로딩

📦 8. 다듬기

🧠 다듬기 개요

  • C++의 모든 일반적인 기법이나 기능에는 사용하는 것이 합당한 상황과 그렇지 않은 상황이 있다. 대체로 판단이 간단하지만, 그렇지 않은 기법과 기능도 있다.
  • 이번 장에서는 그런 기법과 기능을 한 가지씩 다룬다.
    • 기법: 값 전달
    • 기능: 생성 삽입
  • 언제 사용할지 결정하는 데 관여하는 요인이 너무 많아서, 최상의 조언은 사용을 “고려하라(consider)”는 것이다.
    • (옮긴이) 이 책의 조언 강도는 “사용하라/하지 말라(피하라)” > “선호하라” > “고려하라” 순이며, “고려하라”가 가장 약한 형태다.
  • 그래도 값 전달과 생성 삽입은 효과적인 현대적 C++ 프로그래밍에서 중요한 역할을 한다.

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

🔍 문제 상황: 애초에 복사되도록 만들어진 매개변수

  • 함수 매개변수 중에는 애초에 복사되도록 만들어진 것들이 있다.
    • 이 항목에서 매개변수를 “복사한다”는 것은 일반적으로 그것을 복사나 이동 연산의 원본으로 사용하는 것을 뜻한다.
  • 예를 들어 멤버 함수 addName은 자신의 매개변수를 비공개 자료 멤버 컨테이너에 저장한다.
  • 효율성을 위해서는 lvalue 인수는 복사하고, rvalue 인수는 이동하는 것이 바람직하다.
Widget w;
…
std::string name("Bart");

w.addName(name);                 // lvalue를 전달
…
w.addName(name + "Jenne");       // rvalue를 전달

🔍 세 가지 구현 접근방식

접근방식 1: lvalue와 rvalue에 대한 오버로딩

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)); }    // 이동한다;
  …                                           // std::move의 용법은 항목 25를 보라

private:
  std::vector<std::string> names;
};
  • 작동은 하지만, 본질적으로 같은 일을 하는 함수를 두 개 작성해야 한다.
    • 선언, 구현, 문서화, 유지보수할 함수가 모두 두 개씩이다.
    • 목적 코드(object code)에도 함수가 두 개 존재한다. 두 함수가 모두 인라인화되지 않으면 실행 프로그램의 크기가 커진다.

접근방식 2: 보편 참조를 받는 함수 템플릿

class Widget {
public:
  template<typename T>                        // lvalue와 rvalue를
  void addName(T&& newName)                   // 모두 받는다;
  {                                           // lvalue는 복사하고
    names.push_back(std::forward<T>(newName));  // rvalue는 이동한다;
  }                                           // std::forward의 용법은 항목 25를 보라
  …
};
  • 소스 코드는 줄어들지만 보편 참조와 관련된 곤란한 문제점들이 있다.
    • 템플릿이므로 구현을 헤더 파일에 두어야 한다(대부분의 컴파일러에서).
    • 목적 코드에 이 템플릿의 서로 다른 인스턴스가 여러 개 포함될 수 있다. lvalue/rvalue, 그리고 std::string으로 변환 가능한 타입들에 대해 다르게 인스턴스화되기 때문이다(항목 25).
    • 보편 참조로는 전달할 수 없는 인수 타입이 존재한다(항목 30).
    • 클라이언트가 부적절한 타입의 인수를 전달하면 컴파일러가 난해한 오류 메시지를 출력할 수 있다(항목 27).

접근방식 3: 값 전달

class Widget {
public:
  void addName(std::string newName)                // lvalue나 rvalue를
  { names.push_back(std::move(newName)); }         // 받아서 이동한다

  …
};
  • 이 코드에서 자명하지 않은 부분은 매개변수 newName에 std::move를 적용하는 것뿐이다.
    • 보통 std::move는 rvalue 참조에 쓰이지만, 이 경우에는 다음 두 가지를 알고 있으므로 완벽하게 합당하다.
      1. newName은 호출자가 넘겨준 것과는 완전히 독립적인 객체이므로, 변경해도 호출자에게 영향을 미치지 않는다.
      2. 이것이 newName의 마지막 사용이므로, 다른 대상으로 이동해도 함수의 나머지 부분에 영향을 미치지 않는다.
  • 함수가 하나이므로 소스 코드와 목적 코드에서 코드 중복이 없다. 템플릿과 보편 참조를 사용하지 않으므로 헤더 파일 크기, 전달 못 하는 인수 타입, 난해한 오류 메시지 문제도 없다.
  • 그렇다면 효율성은? 값 전달은 비용이 크다고 알려져 있다.
    • C++98: 호출자가 무엇을 넘겨주든 매개변수 newName이 복사 생성에 의해 생성되었다.
    • C++11: newName은 인수가 lvalue일 때만 복사 생성되고, rvalue일 때는 이동 생성된다.
Widget w;
…
std::string name("Bart");

w.addName(name);                 // addName을 lvalue로 호출
…
w.addName(name + "Jenne");       // addName을 rvalue로 호출
                                 // (아래 설명 참고)
  • 첫 호출에서는 newName이 lvalue로 초기화되므로 복사 생성된다.
  • 둘째 호출에서는 operator+ 호출로부터 생성되는 std::string 객체(rvalue)로 초기화되므로 이동 생성된다.
  • 즉, 원했던 그대로 lvalue는 복사되고 rvalue는 이동된다.

🔍 세 접근방식의 비용 비교

(컴파일러가 최적화로 복사, 이동 연산을 제거할 가능성은 무시한다.)

  • 오버로딩 구현: 인수가 lvalue든 rvalue든 참조 newName에 묶이므로 복사, 이동 비용은 없다. 함수 본문에서 lvalue는 names로 복사되고 rvalue는 이동된다.
    • 총비용: lvalue 복사 1회, rvalue 이동 1회
  • 보편 참조 구현: 오버로딩과 같다. 참조에 묶이는 것은 비용이 없고, 본문에서 std::forward 때문에 lvalue는 복사되고 rvalue는 이동된다.
    • 총비용: lvalue 복사 1회, rvalue 이동 1회
    • (항목 25에서 설명했듯이 호출자가 std::string 이외의 타입을 넘기면 std::string 생성자로 전달되어 복사/이동이 0회 이상 일어날 수 있다. 하지만 분석에 영향이 없으므로 항상 std::string을 넘긴다고 가정한다.)
  • 값 전달 구현: 인수가 lvalue든 rvalue든 매개변수 newName이 반드시 생성된다. 본문에서는 newName이 항상 names로 이동된다.
    • 총비용: lvalue 복사 1회 + 이동 1회, rvalue 이동 2회
    • 참조 전달 접근방식들에 비해 lvalue와 rvalue 모두 이동이 하나 더 많다.

⚠️ 제목의 네 가지 핵심 문구

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

① “사용하라”가 아니라 “고려하라”

  • 값 전달은 함수를 하나만 작성하면 되고, 목적 코드에도 함수가 하나뿐이며, 보편 참조 관련 문제점이 없다는 장점이 있다.
  • 그러나 다른 대안들보다 비용이 크며, 아직 이야기하지 않은 비용이 추가되는 경우도 있다.

② “복사 가능” 매개변수에 대해서만

  • 복사할 수 없는 매개변수는 반드시 이동 전용 타입이다. 복사 불가능 매개변수의 복사본을 만든다면 그것은 반드시 이동 생성자를 통해 생성된다.
  • 오버로딩에 비한 값 전달의 장점은 함수가 하나라는 것인데, 이동 전용 타입에서는 lvalue 인수를 위한 오버로딩을 따로 둘 필요가 없다. 복사 생성자가 비활성화되어 있기 때문이다.
  • 따라서 rvalue 참조를 받는 오버로딩 하나만 제공하면 된다.
  • std::unique_ptr<std::string> 자료 멤버와 그에 대한 설정 함수(setter)가 있는 클래스를 보자. std::unique_ptr는 이동 전용 타입이다.
class Widget {
public:
  …
  void setPtr(std::unique_ptr<std::string>&& ptr)    // 오버로딩 접근방식
  { p = std::move(ptr); }

private:
  std::unique_ptr<std::string> p;
};
Widget w;
…
w.setPtr(std::make_unique<std::string>("Modern C++"));
  • std::make_unique(항목 21)가 돌려준 std::unique_ptr<std::string>은 rvalue 참조로서 setPtr에 전달되고, setPtr는 그것을 자료 멤버 p로 이동한다.
    • 총비용: 이동 1회
  • 만일 setPtr가 매개변수를 값으로 받는다면 어떨까?
class Widget {
public:
  …
  void setPtr(std::unique_ptr<std::string> ptr)     // 값 전달 접근방식
  { p = std::move(ptr); }
  …
};
  • 매개변수 ptr이 이동 생성되고, 그 ptr이 자료 멤버 p로 이동 대입된다.
    • 총비용: 이동 2회(오버로딩 접근방식의 두 배)

③ “이동이 저렴한” 매개변수에 대해서만

  • 이동이 저렴한 경우에는 이동이 한 번 더 일어나도 큰 문제가 되지 않는다.
  • 그러나 이동의 비용이 크다면, 불필요한 이동을 수행하는 것은 불필요한 복사를 수행하는 것과 비슷하다.
  • 불필요한 복사 연산을 피하는 것은 여전히 중요하며, 애초에 C++98에서 값 전달을 피하라는 규칙은 바로 그것 때문에 생긴 것이다.

④ “항상 복사되는” 매개변수에 대해서만

  • addName이 매개변수를 names 컨테이너로 복사하기 전에 이름의 길이를 점검해서, 너무 짧거나 길면 추가 요청을 무시한다고 하자.
class Widget {
public:
  void addName(std::string newName)
  {
    if ((newName.length() >= minLen) &&
        (newName.length() <= maxLen))
    {
      names.push_back(std::move(newName));
    }
  }
  …

private:
  std::vector<std::string> names;
};
  • 이 함수는 names에 아무것도 추가하지 않는 경우에도 newName의 생성과 파괴 비용을 유발한다.
  • 이는 참조 전달 접근방식들에서는 치를 필요가 없는 비용이다.

⚠️ 복사 방식에 따른 차이: 생성 vs 대입

이동이 저렴한 복사 가능 타입에 대해 항상 복사를 수행하는 함수라고 해도 값 전달이 적합하지 않은 경우가 있다. 함수가 매개변수를 복사하는 방식이 두 가지이기 때문이다.

  • 생성을 통한 복사(복사 생성이나 이동 생성)
  • 대입을 통한 복사(복사 대입이나 이동 대입)

addName은 생성을 사용한다. newName을 vector::push_back에 넘기고, 그 함수가 복사 생성으로 newName을 std::vector의 끝에 생성된 새 요소로 복사한다.

  • 생성을 통해 매개변수를 복사하는 함수의 비용은 앞에서 분석한 그대로다. 값 전달을 사용하면 다른 대안들에 비해 이동이 한 번 더 수행된다(lvalue 인수와 rvalue 인수 모두).

대입을 통해 복사하는 함수에서는 상황이 더 복잡해진다. 패스워드를 나타내는 클래스와 설정 함수 changeTo를 값 전달로 구현해 보자.

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);
  • 주어진 패스워드로 p.text가 생성되는데, 생성자에서 값 전달이 쓰이므로 std::string 이동 생성 1회의 비용이 발생한다. 오버로딩이나 완벽 전달을 사용했다면 그 비용을 피할 수 있었을 것이다. 이는 모두 예상대로다.
  • (옮긴이) Supercalifragilisticexpialidocious는 영화 메리 포핀스에 나오는 노래의 제목이자 일종의 감탄사다.

이제 패스워드를 바꾸는 상황을 보자.

std::string newPassword = "Beware the Jabberwock";
p.changeTo(newPassword);
  • changeTo에 전달된 인수는 lvalue(newPassword)이므로 매개변수 newPwd가 생성될 때 std::string의 복사 생성자가 호출된다. 그 생성자는 새 패스워드를 담을 메모리를 할당한다.
  • 그런 다음 newPwd가 text로 이동 대입되는데, 이에 의해 text가 차지하던 메모리가 해제된다.
  • 따라서 changeTo 안에서 동적 메모리 관리 동작이 두 번(할당 1회, 해제 1회) 일어난다.
  • 그런데 이 예에서 기존 패스워드(“Supercalifragilisticexpialidocious”)가 새 패스워드(“Beware the Jabberwock”)보다 길다. 따라서 메모리를 할당하거나 해제할 필요가 없다. 오버로딩 접근방식을 사용한다면 할당과 해제를 완전히 생략할 수 있다.
class Password {
public:
  …
  void changeTo(const std::string& newPwd)    // lvalue를 위한
  {                                           // 오버로딩
    text = newPwd;                            // 만일 text.capacity() >= newPwd.size()
                                              // 이면 text의 메모리를 재사용할 수 있다
  }

  …

private:
  std::string text;                           // 이전과 동일
};
  • 이 시나리오에서 값 전달의 비용에는 여분의 메모리 할당과 해제가 포함된다. 동적 메모리 할당 및 해제 비용은 std::string 이동 연산의 수십, 수백 배일 가능성이 크다.
  • 흥미롭게도 기존 패스워드가 새것보다 짧다면 대체로 대입 도중 할당-해제 쌍을 피하는 것이 불가능하며, 그 경우 값 전달이 참조 전달과 거의 비슷한 빠르기로 실행된다.
  • 따라서 대입 기반 매개변수 복사의 비용은 대입에 관여하는 객체의 값에 의존한다.
    • 이런 분석은 동적 할당 메모리에 값을 담는 모든 매개변수 타입에 적용된다(std::string, std::vector 등).
  • 일반적으로 이러한 잠재적 비용 증가는 lvalue 인수가 전달되었을 때만 적용된다. 메모리 할당과 해제는 진짜 복사 연산(이동이 아닌)이 수행될 때만 필요하기 때문이다. rvalue 인수는 거의 항상 이동으로 충분하다.
  • 정리하면, 대입을 통해 복사하는 함수에서 값 전달의 추가 비용은 다음에 의존한다.
    • 전달되는 타입
    • lvalue 인수 대 rvalue 인수의 비율
    • 타입이 동적 메모리 할당을 사용하는지 여부
    • 동적 메모리 할당을 사용한다면 그 타입의 대입 연산자의 구현 방식, 그리고 대입 대상에 연관된 메모리가 대입의 원본에 연관된 메모리만큼 큰지 여부
    • std::string의 경우 구현이 작은 문자열 최적화(SSO, 항목 29)를 사용하는지, 사용한다면 대입되는 값이 SSO 버퍼에 들어가는 크기인지 여부

🔍 값 전달을 둘러싼 현실적인 판단

  • 매개변수가 대입을 통해서 복사될 때는 비용 분석이 복잡해진다. 대체로 가장 실용적인 접근방식은 무죄추정 방침(“유죄가 확정될 때까지는 무죄”)을 적용하는 것이다.
  • 즉, 필요한 매개변수 타입에 대해 값 전달 방식이 충분히 효율적인 코드를 생성한다는 점이 확실하지 않다면 그냥 오버로딩이나 보편 참조를 사용하면 된다.
  • 결론적으로 최대한 빨라야 하는 소프트웨어에서는 값 전달이 그리 바람직하지 않은 전략이다. 저렴한 이동 연산이라도 피하는 것이 중요하기 때문이다. 게다가 이동 연산이 몇 번 일어나는지가 확실하지 않은 경우도 있다.
    • Widget::addName 예제에서는 값 전달의 추가 비용이 이동 연산 하나뿐이었지만, 만일 addName이 Widget::validateName이라는 함수를 호출하며 그 함수 역시 값 전달 방식이라면 어떻게 될까?
    • 그리고 validateName 역시 또 다른 값 전달 방식의 함수를 호출할 수도 있다.
  • “저렴한 이동 1회의 비용만 치르면 된다”는 이유로 값 전달을 사용하는 함수들이 꼬리를 물고 호출되면 전체적인 호출 연쇄의 비용은 허용할 수 없는 수준으로 높아질 수 있다. 참조 전달 매개변수를 사용하는 함수들의 호출 연쇄에서는 오버헤드가 그런 식으로 누적되지 않는다.

⚠️ 잘림 문제(slicing problem)

  • 성능과는 무관하지만, 참조 전달과 달리 값 전달에서는 잘림 문제가 발생할 여지가 있다. 이 문제는 이미 C++98 시절에 충분히 논의된 것이다.
  • 요점: 함수가 부모 클래스 타입이나 그로부터 파생된 임의의 타입의 매개변수를 받는 경우에는 그 매개변수를 값 전달 방식으로 선언하지 않는 것이 좋다. 값 전달로 선언하면 자식 타입의 객체가 전달되었을 때 그 객체의 자식 클래스 부분이 “잘려 나가기(slice off)” 때문이다.
class Widget { … };                           // 부모 클래스

class SpecialWidget: public Widget { … };     // 자식 클래스

void processWidget(Widget w);                 // Widget과 그로부터 파생된 임의의
…                                             // 타입을 받는 함수;
                                              // 잘림 문제가 있다

SpecialWidget sw;
…

processWidget(sw);                            // processWidget은 이를
                                              // SpecialWidget이 아니라
                                              // Widget으로 인식한다!
  • 잘림 문제가 C++98에서 값 전달이 인기가 없었던 또 다른 이유(효율성 문제와 더불어)다. C++ 프로그래밍 학습에서 처음으로 배우는 것 중 하나가 사용자 정의 타입의 객체를 값으로 전달하지 말라는 것인 데에는 이처럼 그럴 만한 이유가 있다.

🔍 C++11에서 달라진 점

  • 값 전달에 관한 C++98 시절의 조언은 C++11에서도 여전히 유효하다. 값 전달은 여전히 성능 하락을 유발할 수 있고, 잘림 문제로 이어질 수 있다.
  • C++11에서 달라진 것은 lvalue 인수와 rvalue 인수가 구분된다는 점이다. 복사 가능 타입의 rvalue에 대한 이동 개념의 장점을 취하도록 함수를 구현하려면 오버로딩이나 보편 참조를 사용해야 하는데, 둘 다 단점이 있다.
  • 이동이 저렴하고, 항상 복사되는 복사 가능한, 그리고 잘림 문제가 발생할 여지가 없는 매개변수의 경우에는 값 전달 접근방식이 참조 전달 접근방식들만큼이나 효율적이면서도 그 접근방식들의 단점이 없는 대안이 된다. 게다가 구현하기도 쉽다.

🧐 정리

  • 이동이 저렴하고 항상 복사되는 복사 가능 매개변수에 대해서는 값 전달이 참조 전달만큼이나 효율적이고, 구현하기가 더 쉽고, 생성되는 목적 코드의 크기도 더 작다.
  • lvalue 인수의 경우 값 전달(즉, 복사 생성) 다음의 이동 대입은 참조 전달 다음의 복사 대입보다 훨씬 비쌀 가능성이 있다.
  • 값 전달에서는 잘림 문제가 발생할 수 있으므로, 일반적으로 부모 클래스 매개변수 타입에 대해서는 값 전달이 적합하지 않다.

Cpp 카테고리 내 다른 글 보러가기

댓글남기기