[Modern C++] 항목 41: 이동이 저렴하고 항상 복사되는 복사 가능 매개변수에 대해서는 값 전달을 고려하라
카테고리: Cpp
이 글은 아래의 책을 정리하였습니다.
이펙티브 모던 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 참조에 쓰이지만, 이 경우에는 다음 두 가지를 알고 있으므로 완벽하게 합당하다.newName은 호출자가 넘겨준 것과는 완전히 독립적인 객체이므로, 변경해도 호출자에게 영향을 미치지 않는다.- 이것이
newName의 마지막 사용이므로, 다른 대상으로 이동해도 함수의 나머지 부분에 영향을 미치지 않는다.
- 보통
- 함수가 하나이므로 소스 코드와 목적 코드에서 코드 중복이 없다. 템플릿과 보편 참조를 사용하지 않으므로 헤더 파일 크기, 전달 못 하는 인수 타입, 난해한 오류 메시지 문제도 없다.
- 그렇다면 효율성은? 값 전달은 비용이 크다고 알려져 있다.
- C++98: 호출자가 무엇을 넘겨주든 매개변수
newName이 복사 생성에 의해 생성되었다. - C++11:
newName은 인수가 lvalue일 때만 복사 생성되고, rvalue일 때는 이동 생성된다.
- C++98: 호출자가 무엇을 넘겨주든 매개변수
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 인수의 경우 값 전달(즉, 복사 생성) 다음의 이동 대입은 참조 전달 다음의 복사 대입보다 훨씬 비쌀 가능성이 있다.
- 값 전달에서는 잘림 문제가 발생할 수 있으므로, 일반적으로 부모 클래스 매개변수 타입에 대해서는 값 전달이 적합하지 않다.
댓글남기기