class Widget {
public:
void addName(const std::string& newName) { names.push_back(newname); }
void addName(std::string&& newName) { names.push_back(std::move(newName)); }
...
private:
std::vector<std::string> names;
};
보편 참조를 사용하면 코드를 더 줄일 수 있을 것 같다.
class Widget {
public:
template<typename T>
void addName(T&& newName) { names.push_back(forward<T>(newName)); }
};
깔끔해졌지만 선언과 구현을 분리시킬 수 없기 때문에 구현부가 반드시 헤더파일에 있어야만 하는 문제가 있다.
게다가 왼값과 오른값에 대해 다르게 인스턴스화 되고 다른 타입들에 대해서도 다르게 인스턴스화 되어서 목적 파일이 커질 가능성이 있다. 뿐만 아니라 보편 참조로 전달하지 못하는 타입도 존재하고 부적절한 타입의 인수를 전달하면 난해한 에러 메시지를 출력할 수도 있다.
addName처럼 왼값은 복사하고 오른값은 이동하되 소스 코드와 목적 코드 양쪽에서 함수 하나만으로 다룰 수 있고 보편 참조의 여러 문제점들도 피할 수 있으면 완벽할 것만 같다.
이 문제점은 여지껏 준수해왔던 규칙중 하나를 포기하면 해결할 수 있다. 바로 사용자 정의 타입의 객체는 값으로 전달하지 말라는 것이다.
class Widget {
public:
void addName(std::string newName) { names.push_back(std::move(newName)); }
...
};
std::move로 매개변수를 오른값으로 넘겨주지만 복사본을 이동시키는 것이기 때문에 호출자에게 영향이 미치지 않는다.
함수도 단 하나이기 때문에 코드 중복도 없고 위에서 언급한 문제점들도 발생하지 않는다.
그런데 값 전달로 인한 복사는 비용이 크다고 알고 있지 않았나?
그건 C++98까지의 이야기이고 C++11에서는 인수가 왼값일때만 복사되고 오른값일 때에는 이동 생성에 의해 생성된다.
Widget w;
std::string name("Bart");
w.addName(name); // 왼값 호출
w.addName(name + "Jenne"); // 오른값 호출
왼값 호출이 일어나는 경우 C++98때처럼 복사 생성이 일어난다. 하지만 오른값 호출이 일어나는 경우 이동 생성이 일어난다.
다만 주의사항이 몇가지 있다.
1. 왼값과 오른값에 대한 함수 오버로딩
호출자가 넘겨준 인수가 왼값이든 오른값이든, 해당 인수는 newName이라는 참조에 묶인다.
왼값의 경우 복사 1회, 오른값의 경우 이동 1회가 일어난다.
2. 보편 참조
함수 오버로딩과 마찬가지로 호출자의 인수는 newName 참조에 묶인다.
마찬가지로 왼값의 경우 복사 1회, 오른값의 경우 이동 1회가 일어난다.
3. 값 전달
호출자의 인수가 왼값이든 오른값이든 매개변수 newName은 반드시 생성된다.
왼값의 경우 복사 생성 1회, 오른값의 경우 이동 생성 1회가 일어난다.
다만 위의 구문같은 경우 move 연산이 한번 더 이뤄지므로 이동 1회가 추가된다.
그래서 무엇이 좋다는걸까?
항목의 제목을 다시 살펴볼 필요가 있다.
"이동이 저렴하고 항상 복사되는 복사 가능 매개변수에 대해서는 값 전달을 고려하라"
값 전달을 고려
값 전달을 "사용하라" 가 아니라 "고려하라" 이다.
장단점이 존재하기 때문에 값 전달이 정답이라고 말하기에는 무리가 있다.
복사 가능 매개변수
복사 가능 매개변수에 대해서만 값 전달을 고려해야 한다.
복사할 수 없는 매개변수는 반드시 이동 전용 타입일 것이고 해당 매개변수의 복사본이 생성된다면 반드시 이동 생성자를 통해서 생성된다.
또한 이동 전용 타입은 복사 생성자가 비활성화 되어있기 때문에 왼값 인수를 위한 함수 오버로딩을 정의해둘 필요가 없고 오른값 인수를 위한 함수만 제공하면 된다.
예를 들어 std::unique_ptr<std::string> 멤버 변수와 setter 멤버 함수가 있는 클래스를 생각해보자.
class Widget {
public:
...
void setPtr(std::unique_ptr<std::string>&& ptr) { p = std::move(ptr); } // 보편 참조
private:
std::unique_ptr<std::string> p;
};
이 때 오른값으로 setter를 호출하면 인수는 매개변수 ptr의 참조에 묶이게 되어 비용이 발생하지 않고 std::move에 의한 이동 연산 1회만 일어난다.
void setPtr(std::unique_ptr<std::string> ptr) { p = std::move(ptr); }
그런데 매개변수가 값으로 받게 구현되어 있다면 매개변수 ptr은 이동생성되고 std::move에 의해 한번 더 이동되어 총 2회의 이동 연산이 일어난다.
함수 오버로딩 방식에 비해 2배의 비용이 발생하게 된다.
이동이 저렴
이동이 저렴한 경우에는 이동 전용 타입을 값으로 전달하더라도 비용이 크게 문제되지 않는다.
항상 복사되는
class Widget {
public:
void addName(std::string newName) {
if ((newName.legnth() >= minLen) &&
(newName.length() <= maxLen))
{
names.push_back(std::move(newName));
}
}
private:
std::vector<std::string> names;
};
문자열의 길이가 너무 짧으면 컨테이너에 추가하지 않는 멤버 함수 addName의 구현이다.
이 구현의 문제는 조건이 충족되지 않아서 컨테이너에 아무것도 추가하지 않더라도 newName의 생성과 파괴는 함수 호출마다 이루어진다는 점이다. 만약 참조 전달 접근방식들이라면 발생하지 않았을 비용이다.
안타깝게도 이동이 저렴하고 복사 가능 타입에 대해 항상 복사를 수행하는 함수라고 하더라도 값 전달이 적합하지 않은 경우가 종종 발생한다.
그 이유는 생성에 의한 복사, 대입에 의한 복사 두 종류가 존재하기 때문이다.
생성에 의한 매개변수의 복사가 이뤄지는 경우 std::move호출에 의해 이동 연산이 한번씩 더 수행된다.
대입에 의한 매개변수의 복사가 이뤄지는 경우는 상황이 조금 더 복잡해진다.
class Password {
public:
explicit Password(std::string pwd) : text(std::move(pwd)) {} // 생성에 의한 복사
void changeTo(std::string newPwd) { text = std::move(newPwd); } // 대입에 의한 복사
private:
std::string text;
};
std::string initPwd("very important and long long password");
Password p(initPwd);
이 경우 생성자에서 값 전달이 사용되기 때문에 이동 생성 1회의 비용이 발생한다. 오버로딩이나 완벽 전달을 사용했다면 발생하지 않는다.
std::string newPassword = "another password";
p.changeTo(newPassword);
도중에 값을 바꿀일이 발생하면 이제는 생성자가 아닌 대입 연산이 이뤄져야 한다.
이 때, 함수의 값 전달 접근방식 때문에 비용이 아주 커질 가능성이 생긴다.
changeTo에 왼값 인수가 전달되기 때문에 매개변수 newPwd가 생성될 때는 std::string의 복사 생성자가 호출된다.
std::string의 복사 생성자는 새로운 메모리를 할당하고 기존 메모리를 해제하게 된다.
생성과 해제 총 2번의 동적 메모리 관리가 일어나게 되는 것이다.
그런데 기존에 저장된 문자열이 새로운 문자열보다 더 길기때문에 메모리를 해제하지 않고 그대로 사용해도 무방하지만 별도로 처리하지 않으면 메모리 관리 2회가 발생하게 된다.
만약 기존에 저장된 문자열이 새로운 문자열보다 짧은 경우에는 반드시 메모리의 할당-해제가 일어나고 값 전달이나 참조 전달이나 비슷한 속도로 동작하게 된다.
한마디로 대입 기반 매개변수 복사의 비용은 대입에 관여하는 객체의 값에 의존하게 되는 것이다.
보통 이런 잠재적인 비용 증가는 왼값 인수가 전달되어 실제 복사 연산이 일어날 때만 필요하고 오른값 인수는 대부분 이동 연산으로 충분하다.
값 전달 방식이 효율적인 코드를 산출한다는 점을 확신할 수 없다면 오버로딩이나 보편 참조를 사용하면 된다.
이번 항목에서 다뤘던 예제에 한해서는 값 전달 방식이 1회의 이동 연산만 추가되었지만 연쇄적으로 값 전달 방식 함수 호출이 여러번 이루어진다면 연산 횟수가 누적되어 비용이 상당히 커질 수 있다.
성능과 무관하게 값 전달은 잘림 문제(slicing problem)가 발생할 여지가 있다.
함수가 기반 클래스 타입이나 파생 클래스 타입을 매개변수로 받는 경우 해당 매개변수는 값 전달 방식으로 선언하지 않는 것이 좋다.
◾ 이동이 저렴하고 항상 복사되는 복사 가능 매개변수에 대해서는 값 전달이 참조 전달만큼이나 효율적이고, 구현하기가 더 쉽고, 산출되는 목적 코드의 크기도 더 작다.
◾ 왼값 인수의 경우 값 전달(즉, 복사 생성) 다음의 이동 대입은 참조 전달 다음의 복사 대입보다 훨씬 비쌀 가능성이 있다.
◾ 값 전달에서는 잘림 문제가 발생할 수 있으므로, 일반적으로 기반 클래스의 매개변수 타입에 대해서는 값 전달이 적합하지 않다.
'도서 > Effective Modern C++' 카테고리의 다른 글
[8장] 항목 42 : 삽입 대신 생성 삽입을 고려하라 (0) | 2023.01.31 |
---|---|
[7장] 항목 40 : 동시성에는 std::atomic을 사용하고, volatile은 특별한 메모리에 사용하라 (0) | 2023.01.31 |
[7장] 항목 39 : 단발성 사건 통신에는 void future 객체를 고려하라 (0) | 2023.01.31 |
[7장] 항목 38 : 스레드 핸들 소멸자들의 다양한 행동 방식을 주의하라 (0) | 2023.01.30 |
[7장] 항목 37 : std::thread들을 모든 경로에서 합류 불가능하게 만들어라 (0) | 2023.01.30 |