-
[Effective Modern C++] 값 전달을 고려할만한 상황C++/Effective Modern C++ 2023. 6. 9. 14:04
값 전달(Pass by value)은 C++에서 기피되는 방식입니다.
전달 과정에서 이미 복사(이동)연산이 추가적으로 발생하며, 그 외에도 일어날 수 있는 추가적인 문제가 있기 때문입니다.
이에 권장되는 전달 방식은 참조 전달(Pass by reference)입니다.
하지만, 이번 글에서는 그럼에도 불구하고 값 전달을 고려할 수 있는 경우를 살펴볼 것 입니다.
이번 글에서 살펴 볼 예제 코드의 시나리오는 다음과 같습니다.
MyClass라는 클래스 내의 멤버 함수를 다룹니다.
이 함수는 문자열을 받아서 자신의 문자열 배열 멤버 변수에 저장합니다.위 내용의, 멤버 함수를 제외한 클래스 구현은 다음과 같습니다.
class MyClass { public: // Function private: std::vector<std::string> names; };
본문에서는 멤버 함수를 구현하는 여러가지 방법에 대하여 다루도록 하겠습니다.
1. Pass by reference
더보기참조 전달을 통해 구현한 함수는 다음과 같을 수 있습니다.
// L-value reference void addName(const std::string& newName) { names.push_back(newName); } // R-value reference void addName(std::string&& newName) { names.push_back(std::move(newName)); }
L,R value에 대한 함수의 구현이 다릅니다.
이로 인해 한 기능을 하는 함수가 두 개가 되었습니다.
함수가 두 개가 되었다는 것은, 문서화, 유지보수, 프로그램의 크기도 그만큼 증가한다는 것 입니다.
비용 측면에서는 다음과 같습니다.
전달 : 참조 전달이기 때문에 비용이 없습니다.
본문 : names에서 각각 복사(L-value)1회, 이동(R-value)1회가 발생합니다.
종합 : L-value일 경우 복사 1회, R-vlaue일 경우 이동 1회
2. Universal reference
더보기보편 참조를 이용한 구현은 다음과 같습니다.
template<typename T> void addName(T&& newName) { names.push_back(std::forward<T>(newName)); }
이 함수는 위 문단의 참조 구현과 비교하여, 구현할 함수가 하나로 줄었습니다.
하지만, 그와는 별개로 다른 문제가 발생할 수 있습니다.
우선, 템플릿을 이용한 구현이기 때문에, std::string으로 변환될 수 있는 여러 값에 대하여 인스턴스화 될 가능성이 있습니다.
또한, 위 경우 (여러 형식에 대한 인스턴스화)에 겹쳐서, std::forward가 실패하는 경우가 있습니다.
이에 대해서는 다른 글 (완벽 전달의 실패)에서 다루었습니다.
비용 측면에서는 다음과 같습니다.
전달 : 참조 전달이기 때문에 비용이 없습니다.
본문 : names에서 각각 복사(L-value)1회, 이동(R-value)1회가 발생합니다.
종합 : L-value일 경우 복사 1회, R-vlaue일 경우 이동 1회
다만, 이 구현은 템플릿이기 때문에, 위 통계가 정확하지 않을 수 있습니다.
예를 들어, std::string이외의 매개변수에 의한 인스턴스는 std::string의 생성으로 인한 복사, 이동 연산이 발생할 수 있습니다.
3. Pass by value
더보기값 전달 방식의 구현은 다음과 같습니다.
void addName(std::string newName) { names.push_back(std::move(newName)); }
해당 코드의 본문에 대하여, std::move를 사용한 이유는 다음과 같습니다.
- 호출자가 전달한 객체와 newName은 서로 독립적인 객체입니다. 따라서 본문의 행동이 호출자에게 영향을 미치지 않습니다.
- push_back이 newName의 마지막 사용입니다. 객체를 이동하더라도, 함수의 다른 부분에 영향을 미치지 않습니다.
비용 측면에서는 다음과 같습니다.
전달 : 전달된 값에 대하여, newName의 생성자는 무조건 호출됩니다. 하지만, L-value에 대하여 복사 생성되며, R-value에 대하여 이동 생성된다는 차이가 존재합니다.
본문 : 모든 경우에서 newName의 이동 연산이 1회 발생합니다.
종합 : L-value일 경우 복사 1회 + 이동 1회, R-vlaue일 경우 이동 2회
4. Non-copyable type
더보기값 전달 방식에 대하여, 복사가 불가능한 형식에 대해 추가로 살펴보도록 하겠습니다.
복사 불가능한 형식의 예시는 std::unique_ptr이 있습니다.
이 경우에 대한 코드를 우선적으로 살펴보면 다음과 같습니다.
우선, 참조 전달 방식입니다.
class MyClass2 { public: void setPtr(std::unique_ptr<std::string>&& ptr) { p = std::move(ptr); } private: std::unique_ptr<std::string> p; };
복사 불가능한 형식은 L-vlaue에 대한 참조가 불필요하기 때문에, 한 가지 함수로 충분합니다.
비용은 이동 1회입니다. (p에 대한 이동 배정)
다음은 값 전달 방식입니다.
class MyClass2 { public: void setPtr(std::unique_ptr<std::string> ptr) { p = std::move(ptr); } private: std::unique_ptr<std::string> p; };
비용은 이동 2회입니다. (ptr에 대한 이동 생성, p에 대한 이동 배정)
복사 불가능한 형식의 경우, 위 문단들에서 살펴본 경우처럼 이동 연산이 한번 더 발생합니다.
하지만 위 문단들과의 결정적인 차이점으로, 참조 전달 방식에서 문제점으로 짚었던 함수의 갯수 문제가 발생하지 않는다는 것 입니다.
관리해야 할 함수의 갯수가 하나로 동일한데, 연산의 횟수는 더 많아지므로, 복사 불가능한 객체에 대하여 값 전달 방식은 적합하지 않음이 자명합니다.
5. 그 외
더보기그 외에 살펴볼만한 경우는 다음과 같습니다.
우선, 불필요한 비용이 발생하는 경우입니다.
void addName(std::string newName) { if(newName.length() > 10) names.push_back(std::move(newName)); }
위 함수는 names에 추가되는 newName에 제약을 건 함수입니다.
이 경우, 만약 추가되지 않을 newName이 생성될 경우, 참조 전달에서는 아예 발생하지 않는 불필요한 생성, 소멸 비용이 발생합니다.
다음은 배정 연산입니다.
class MyClass3 { public: // Constructor : text assignment explicit MyClass3(std::string str) : text(std::move(str)) {} // Function : text assignment void changeText(std::string str) { text = std::move(str); } private: std::string text; };
생성과 배정은 별개입니다.
생성을 통한 전달은 위 문단에서 살펴본 것과 같이 1회의 이동이 추가됩니다.
하지만 배정의 경우, 위 MyClass3의 코드를 사용하는 다음 코드를 살펴보도록 하겠습니다.
// Init MyClass3 std::string init("Supercalifragilisticexpialidocious"); MyClass3 cls(init); // Change text std::string newStr("Newstring"); cls.changeText(newStr);
위와 같은 코드를 사용할 경우, 이동, 복사 연산 외에 메모리 할당과 해제 연산이 일어납니다.
이는 이동 연산과는 비교할 수 없을만큼 큰 연산이기 때문에, 값 전달이 적합하지 않습니다.
다음은 Slicing problem에 관한 문제입니다.
이것은 상위 클래스의 값을 받는 함수에 하위 클래스의 변수를 전달할 때, 정보가 잘려나가는 문제입니다.
// 상위 클래스 class Ancestor { ... }; // 하위 클래스 class Descendant : public Ancestor { ... }; void Function(Ancestor cls); ... Descendant d; Function(d); // Problem
위와 같은 상황에서, Function에 전달된 cls는 Descendant형식인 d의 정보가 잘려나가 있습니다.
이 문제는 하위 클래스의 형식으로 변경하거나, 참조로 해결할 수 있는 문제로, 값 전달이 적합하지 않은 예시 중 하나로 자주 쓰이곤 합니다.
참조, 보편참조, 값 전달 방식의 간단한 구현과, 비용에 대해 살펴보았습니다.
그 외에도, 3가지 경우 외에 특별한 경우 (복사 불가능 형식, 메모리 할당 문제, Slicing problem)또한 살펴보았습니다.
특별한 문제의 경우, 내용 자체는 특별하다기 보단 자주 발생하는 이슈입니다.
따라서 값 전달 방식은 상황에 따라 유용하게 쓰일 수 있지만, 쉽게 생각해서는 안될 방법 정도로 판단하는 것이 좋을 것 같습니다.
감사합니다.
'C++ > Effective Modern C++' 카테고리의 다른 글
[Effective Modern C++] insert와 emplace (0) 2023.06.13 [Effective Modern C++] std::atomic VS volatile (0) 2023.05.12 [Effective Modern C++] 스레드 간 단발성 이벤트 통신 (0) 2023.05.04 [Effective Modern C++] std::future의 소멸자 (0) 2023.04.19 [Effective Modern C++] std::thread를 unjoinable하게 만들어야 하는 이유 (0) 2023.04.14