Advanced C++

[Modern C++] (8) 다듬기

로파이 2021. 5. 4. 19:51

시퀀스 컨테이너 (vector/list/string/deque)와 연관 컨테이너 (map/set/unordered_map/set)에서 원소를 삽입하는 방법에 대하여 고려할 사항을 알아본다.

 

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

 

Widget 인스턴스에 string 객체를 추가하는 addName 함수에 대하여 

 

오른값과 왼값을 받는 중복 적재 버전

class Widget
{
private:
	std::vector<std::string> names;
public:
	// 왼값 참조를 받는 버전
	void addName(const std::string& newName)
	{
		names.push_back(newName);
	}
	// 오른값 참조를 받는 버전
	void addName(std::string&& newName)
	{
		names.push_back(std::move(newName));
	}
};

push_back() 연산 행동

  • 왼값 참조에 대해여: 추가 원소를 newName을 복사하여 생성한다.
  • 오른값 참조에 대하여: 추가 원소를 newName을 이동하여 생성한다.
  • 두 버전을 만들어 사용할 경우 유지 보수해야할 함수가 두 개가 되며 목적 코드에서 등록되는 함수도 두 개가 된다.
class Widget
{
private:
	std::vector<std::string> names;
public:
	// 보편 참조를 받는 버전
	template<typename T>
	void addName(T&& newName)
	{
		names.push_back(std::forward<T>(newName));
	}
};

똑같은 기능을 지원하는 addName을 보편 참조 버전으로 만들 수 있다. 보편 참조 버전은 다뤄야할 소스 코드 양이 적어지지만 템플릿 함수의 경우 헤더 파일에서 정의해야하고 사용자 코드에서 다양한 타입에 대하여 산출된 함수가 인스턴스화되어 목적 코드량이 많아지게 된다. 또한 string으로 생성할 수 없는 인수에 대한 문제도 존재한다.

 

사용자 형식의 객체가 아닌 표준 라이브러리에서 제공하는 std::string 객체는 값으로 인자를 전달해줘도 된다.

class Widget
{
private:
	std::vector<std::string> names;
public:
	// 값으로 전달하는 경우
	void addName(std::string newName)
	{
		names.push_back(std::move(newName));
	}
};

이렇게 사용해도 합당한 이유는,

1) 인수가 값으로 복사되기 때문에 외부에서 전달한 객체는 함수 내부 내용에 영향을 받지 않는다.

2) 따라서 인수를 이동하여 새로운 원소를 이동 생성해준다.

 

- C++11에서 값 전달했을 때 인수 생성 방식 변화

Widget w;

// ...

std::string name("Bart");

w.addName(name); // 왼값을 전달

w.addName(name + "York"); // 오른값을 전달

C++98에서는 왼값/오른값 상관없이 인수를 복사 생성하였지만 C++ 11에서 newName에 대해 왼값을 전달할 경우 복사하여 인수를 생성하고 오른값을 전달할 경우 이동하여 인수를 생성한다.

 

두 가지 중복 적재 vs 보편 참조 vs 값 전달 비교

class Widget
{
private:
	std::vector<std::string> names;
public:
	// 왼값 참조를 받는 버전
	void addName(const std::string& newName)
	{
		names.push_back(newName);
	}
	// 오른값 참조를 받는 버전
	void addName(std::string&& newName)
	{
		names.push_back(std::move(newName));
	}
};

class Widget
{
private:
	std::vector<std::string> names;
public:
	// 보편 참조를 받는 버전
	template<typename T>
	void addName(T&& newName)
	{
		names.push_back(std::forward<T>(newName));
	}
};

class Widget
{
private:
	std::vector<std::string> names;
public:
	// 값으로 전달하는 경우
	void addName(std::string newName)
	{
		names.push_back(std::move(newName));
	}
};
Widget w;

// ...

std::string name("Bart");

w.addName(name); // 왼값을 전달

w.addName(name + "York"); // 오른값을 전달

위 클래스 설계를 바탕으로 아래 클라이언트 코드를 작성하면 어떤 일이 일어나는 지 비교해본다.

 

중복 적재의 경우

  • 왼값/오른값: 항상 newName에 참조로 묶이며 왼값일 경우 복사 한 번, 오른값일 경우 이동 한 번이 발생한다.

보편 참조의 경우

  • 중복 적재의 경우와 같다. newName에 참조로 묶이며 왼값일 경우 복사 한 번, 오른값일 경우 이동 한 번이 발생한다.

값 전달의 경우

  • 왼값: 인수를 복사 생성하므로 복사 한 번이 발생한다. 오른값: 인수를 이동 생성하므로 이동 한 번이 발생한다.
  • 마지막으로 값을 이동하여(std::move) 원소를 이동 생성하여 추가한다.
  중복 적재 보편 참조 값 전달
왼값 복사 1회 복사 1회 복사 1회, 이동 1회
오른값 이동 1회 이동 1회 이동 2회

값 전달의 경우 이동이 딱 한번 더 발생하게 된다.

 

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

 

1) 값 전달을 "사용하라"가 아니라 고려하라 일 뿐이다.

- 값 전달 방식은 목적 코드가 하나 인 점이 좋지만 이동 비용에 따라 값 전달 방식이 안 좋을 경우가 있다.

 

2) 복사 가능 매개변수에 대해서만 값 전달을 고려해야 한다.

- 해당 매개변수 타입이 복사 기능을 지원하지 않는 다면 굳이 값 전달을 사용할 필요가 없다. 예를 들면 unique_ptr 같은 객체는 복사 함수가 삭제되어 있고 이동만 지원한다. 이러한 인수를 받는 함수는 값 전달 방식에서 일반 오른값 버전 보다 이동 연산이 1회 더 많다.

class Widget
{
private:
	std::unique_ptr<std::string> p;
public:
  // 그냥 오른값 참조로 받아라
  void SetName(std::unique_ptr<std::string>&& p_in)
  {
  	p = std::move(p_in);
  }
}

 

3) 값 전달은 이동이 저렴한 매개변수에 대해서만 고려한다.

- 이동이 한 번 더 발생하기 때문이다.

 

4) 값 전달은 항상 복사 되는 매개변수에 대해서만 고려해야 한다.

- 함수 내용 안에서 복사가 되지 않는 경우 인수 생성 비용이 낭비될 수 있다.

class Widget
{
private:
	std::vector<std::string> names;
public:
	// 값으로 전달하는 경우
	void addName(std::string newName)
	{
            // 길이 유효함을 체크
    	    if(newName.size() < 10 || newName.size() > 50)
		return;
            names.push_back(std::move(newName));
	}
};

 

값 전달 방식이 더 비싼 경우 - 이동 할당의 경우 (Move Assignment)

class Password
{
public:
//...
  void changeTo(std::string newPwd) // newPwd를 위한 메모리 할당
  {
     text = std::move(newPwd); // 기존 text 메모리 해제 후 이동
  }
private:
  std::string text;
};

생성의 경우 객체를 어차피 생성해야하지만 기존에 있던 객체에 새로운 객체를 이동하는 경우 기존 객체에 할당되어 있던 동적 메모리를 할당해야한다. 이 경우 값 전달 방식에서 인수를 생성할 때 한 번, 기존 객체를 해제할 때 한 번으로 메모리 관리 동작이 두 번 일어난다. 

 

string 객체의 경우 복사를 한다면 기존 메모리 사용이 가능할 수 도 있다. 이러한 경우 값 전달보다 중복 적재를 사용하는 것이 낫다.

class Password
{
public:
//...
  void changeTo(const std::string& newPwd)
  {
     text = newPwd; // text.capcaity() >= newPwd.size()라면 메모리 재사용 가능하다.
  }
private:
  std::string text;
};

 

2. 삽입 대신 생성 삽입을 고려해라.

 

push_back과 emplace_back 혹은 insert와 emplace와 같은 용도의 차이를 알고 emplace_back과 emplace와 같은 생성 삽입을 고려해본다.

 

벡터 컨테이너의 push_back

template<class T, class Allocator = allocator<T>>
class vector{
public:
//...
  void push_back(const T &x); // 왼값을 삽입
  void push_back(T&&); // 오른값을 삽입

};
  

push_back 함수는 왼 값 버전과 오른값 버전 두 가지가 있으며, 왼 값의 경우 해당 인수를 복사하여 새로운 원소를 추가하고 (복사 생성자) 오른값의 경우 이동 생성하여 새로운 원소를 추가한다.

 

vector<string> vs;

// ...

vs.push_back("My Name");

위 코드에서 const char[8] 문자열을 전달하면 어떤 일이 일어날 까

1) "My Name"은 const char[8] 문자열이므로 인수가 일치하는 함수가 없기 때문에 컴파일러는 string 객체를 생성하여 전달하려고 한다. 따라서 다음 코드를 산출하게 된다.

vector<string> vs;

// ...

vs.push_back(std::string("My Name"));

그 과정에서 임시 객체 std::string을 생성하여 "My Name" 인수를 받아 생성한 다음 그 임시 객체는 오른값이므로 두번째 중복 적재를 선택하여 전달하게 된다.

벡터에 새로운 원소를 추가하기 위해 메모리를 할당하고 이에 임시 객체 std::string을 이동하여 생성한다.

임시 객체 std::string이 파괴된다. (소멸자가 실행된다.)

 

따라서 결과적으로 보면 새로운 원소 std::string 객체를 삽입하기 위해 std::string을 두 번 생성하고 있는 것이다.

 

emplace_back 생성 삽입

vector<string> vs;

// ...

vs.emplace_back("My Name");

emplace_back은 기본적으로 보편 참조와 완벽 전달을 사용하여 내부에서 객체를 생성한다. 즉 전달 인수로 가지고 오버로딩할 수 있는 생성자를 적절히 선택하여 새로운 원소를 추가한다. 따라서 const char[8] 전달 매개변수로 임시 객체가 생성되지 않아 생성 연산을 한 번 덜하게 된다.

 

본격적인 비교

다음 Widget 클래스에 정의에 따른 push_back과 emplace_back 결과를 알아보자.

Widget.h

#pragma once
#include <iostream>
#include <string>

using namespace std;

class Widget
{
private:
	string name;
public:
	Widget() 
	{
		cout << "Default Constructor is Called" << endl << endl;
	}

	Widget(string with_name)
	{
		name = std::move(with_name);
		cout << "Overloading Contructor is Called" << endl << endl;
	}

	~Widget()
	{
		PrintName();
		cout << "Destructor is Called" << endl << endl;
	}

	// 복사 생성자
	Widget(const Widget& rhs)
	{
		name = rhs.name;
		cout << "Copy Constructor is Called" << endl << endl;
	};

	// 이동 생성자
	Widget(Widget&& rhs) noexcept
	{
		name = std::move(rhs.name);
		cout << "Move Constructor is Called" << endl << endl;
	}

	// 복사 할당자
	Widget& operator=(Widget& rhs)
	{
		name = rhs.name;
		cout << "Copy Assignment is Called" << endl << endl;
		return *this;
	}

	// 이동 할당자
	Widget& operator=(Widget&& rhs) noexcept
	{
		name = std::move(rhs.name);
		cout << "Move Assignment is Called" << endl << endl;
		return *this;
	}

	void SetName(const string& with_name)
	{
		name = with_name;
	}

	void PrintName() const
	{
		cout << "Widget Name : " << name.c_str() << endl << endl;
	}
};

사용자 코드에서 vector<Widget>에 widget 인스턴스를 추가해보도록 하자.

 

1. 왼값 인수에 대한 push_back과 emplace_back

#include "Widget.h"
#include <vector>
#include <memory>

vector<Widget> widgets;

int test_case = 3;

void left_value_test()
{
	Widget w("My Widget");
	cout.clear();

	// 왼값에 대한 push_back과 emplace_back은 둘 다 복사 생성자를 호출한다.
	widgets.push_back(w);

	widgets.emplace_back(w);

	// 이후에 출력되는 소멸자에 대해 출력하지 않는다.
	cout.setstate(ios_base::failbit);
}

  • push_back: 왼값 인수에 대해 복사 생성을 하여 새로운 원소를 추가한다.
  • emplace_back: 마찬가지로 복사 생성하여 새로운 원소를 추가한다.

둘 다 내부에서 참조자로 왼값을 묶고 복사하기 때문에 성능상 차이는 없다.

 

2. 오른값 인수에 대한 push_back과 emplace_back

void right_value_test()
{
	Widget w1("My Widget1");
	Widget w2("My Widget2");
	cout.clear();

	// 오른값에 대한 push_back과 emplace_back은 둘 다 이동 생성자를 호출한다.
	widgets.push_back(std::move(w1));

	widgets.emplace_back(std::move(w2));

	cout.setstate(ios_base::failbit);
}

  • push_back: 오른값 인수에 대해 이동 생성을 하여 새로운 원소를 추가한다.
  • emplace_back: 마찬가지로 이동 생성을 하여 새로운 원소를 추가한다.

이동 후 Widget 객체의 string 값은 비어있는 상태이다.

 

3. 객체를 직접 생성하여 추가하는 emplace_back 방식을 택하는 경우

위 두 경우에서 바깥에서 Widget 인스턴스를 생성하여 전달하였다. emplace_back은 Widget 인스턴스를 생성하기 위한 인자를 전달하면 직접 생성할 수 있다.

void compare_push_btw_emplace()
{
	cout.clear();
	cout << "=====================push_back========================" << endl;
	/*
		temp 객체에 대해 오버로딩된 생성자를 호출한다.
		컨테이너 안의 Widget 원소에 temp를 인수로 받는 이동 생성자를 호출한다.
		temp 객체가 파괴된다.
	*/
	widgets.push_back(Widget("My Widget1"));
	cout << "=====================emplace_back======================" << endl;
	/*
		emplace_back은 완벽 전달을 이용한다.
		주어진 인수(string)로 바로 오버로딩된 생성자를 호출하여 원소를 생성한다.
	*/
	widgets.emplace_back("My Widget2");

	cout.setstate(ios_base::failbit);
}

3번 째 방식에서 두 함수의 차이가 드러난다. push_back의 경우 Widget 오른값 객체를 생성하기 위해 생성자를 한 번 호출하고 새로운 원소에 대한 이동 생성이 이루어지며 기존 객체는 해제된다. 이 때 중간에 이동이 되기 때문에 Widget Name이 빈 상태로 출력되는 것을 볼 수 있다.

 

emplace_back()

내부에서 직접 생성하기 때문에 이동 연산도 필요 없고 파괴되는 객체도 없다.

 

스마트 포인터와 컨테이너 원소 추가

스마트 포인터 타입를 원소로 갖는 컨테이너에 새로운 원소를 추가할 때, 그리고 스마트 포인터가 커스텀 삭제자를 가지고 있는 경우 생 포인터를 이용하여 스마트 포인터를 초기화 해야한다.

 

따라서 push_back을 사용하여 다음과 같이 원소를 추가할 수 있다.

struct Deleter
{
	void operator()(Widget* w)
	{
		delete w;
	}
};
vector<unique_ptr<Widget, Deleter>> p_widgets;

// ...

p_widgets.push_back(std::unique_ptr<Widget, Delter>(new Widget("my widget"), Deleter());

 

 

std::unique_ptr은 동적 할당 객체의 인스턴스와 삭제자 인스턴스를 전달하여 생성할 수 있으므로 emplace_back을 이용하여 내부에서 원소를 추가하는 것도 가능하다.

struct Deleter
{
	void operator()(Widget* w)
	{
		delete w;
	}
};
vector<unique_ptr<Widget, Deleter>> p_widgets;

// ...

p_widgets.emplace_back(new Widget("My Widget"), Deleter());

 

문제는 위 코드에서 예외를 발생하는 경우 메모리 누수가 발생할 수 있다는 것이다.

1) unique_ptr를 생성하기 위해 생 포인터가 완벽 전달된다.

2) 원소를 추가하기 위한 메모리 할당 중 메모리 부족 예외가 발생한다.

3) 힙 메모리에 접근할 유일한 생 포인터가 사라진다. 즉 메무리 누수가 발상하게 된다. 

 

따라서 emplace_back을 이용한 내부 생성을 피하고 외부에서 먼저 생성한 후 std::move를 이용하여 오른값으로 전달하는 것이 바람직하다.

void smart_pointer_add()
{
	cout.clear();

	p_widgets.emplace_back(new Widget("MyWidget"), Deleter()); // 좋지 않은 사용 법

	auto ws = unique_ptr<Widget, Deleter>(new Widget("MyWidget"), Deleter());

	p_widgets.push_back(std::move(ws)); // 명시적으로 생성해준 다음 이동시킨다.

	cout.setstate(ios_base::failbit);
}