
스마트 포인터의 기본 개념
void FooFunction()
{
Foo* fooPtr = new Foo();
delete fooPtr;
}
위와 같은 함수가 있을 때 메모리 동적할당 후 delete를 하지 않는 경우가 종종 발생하여 메모리 누수가 발생할 수 있다
반드시 기억해서 delete를 한다고 해도 함수가 early return이나 예외처리가 되어버리면서 delete가 되지 않는 경우도 자주 발생한다
동적 할당한 메모리 변수가 delete되지 않는다면 메모리 누수(leak)가 되고 해당 함수가 호출될 때마다 메모리 누수가 누적되어 나중에는 크게 문제가 발생할 수 있다
이런 이슈가 발생하는 가장 큰 이유는 포인터 변수 자체에 스스로 clean up하는 매커니즘이 없기 때문이다 (사용을 다 했으면 clean up해야 하는데 그런 매커니즘이 없음)
클래스의 장점 중 하나는 클래스 객체가 스코프를 벗어나게 되면 소멸자가 자동으로 호출되고 인스턴스화 될 때 생성자가 호출된다는 점이다, 따라서 RAII 프로그래밍 패러다임에서는 생성자에서 메모리를 동적할당 했으면 소멸자에서 메모리를 해제하여 메모리 해제를 보장받을 수 있다는게 핵심이다
클래스를 사용하여 포인터를 관리하고 정리한다고 생각해보자, 이 클래스는 오직 전달받은 포인터를 소유하다가 소멸될 때 전달 받은 포인터를 해제하는 역할만 한다
template <typename T>
class AutoManagePtr
{
public:
AutoManagePtr(T* inPtr) : ptr{ inPtr }
{
}
~AutoManagePtr()
{
delete ptr;
}
T& operator*() const { return *ptr; }
T* operator->() const { return ptr; }
private:
T* ptr{};
};
class Foo
{
public:
private:
int value{};
};
int main()
{
AutoManagePtr<Foo> amp{ new Foo() };
return 0;
}
AutoManagePtr은 생성자에서 전달받은 포인터를 소멸자에서 delete하는 역할을 하고 해당 클래스를 포인터 처럼 사용할 수 있도록 operator*와 ->를 오버로딩한 클래스 템플릿이다
따라서 AutoManagerPtr 생성 시 new Foo()로 Foo 클래스 타입으로 메모리 동적할당을 했고 이 Foo타입 포인터는 main()이 종료되면서 AutoManagePtr클래스에 의해 자동으로 delete가 된다 (따로 delete하지 않아도 메모리 누수 X)
이러한 AutoManagerPtr은 지역변수로 선언이 된다면 해당 스코프가 끝나게 되면 가지고 있는 포인터는 확실하게 delete 됨을 보장할 수 있다
이런 AutoManagerPtr과 같은 클래스를 스마트 포인터라고 부른다, 스마트 포인터는 동적 할당된 메모리를 관리하고 스마트 포인터 객체가 스코프를 벗어날 때 해당 동적 할당된 메모리가 삭제되도록 보장하기 위해 설계된 합성 클래스이다 (composition class)
내장 포인터는 스스로를 정리할 수 없기 때문에 dump pointer라고도 부른다
하지만 위 코드는 명시적으로 복사 생성자나 복사 대입 연산자를 정의하지 않았기 때문에 암시적 복사 생성자 및 복사 대입 연산자가 호출되어 얕은 복사가 일어날 수 있다
int main()
{
AutoManagePtr<Foo> amp{ new Foo() };
AutoManagePtr<Foo> amp2{ amp };
return 0;
}
위 코드는 얕은 복사가 발생하여 amp와 amp2는 동일한 Foo클래스 타입으로 동적할당된 메모리 주소를 가리키기 때문에 소멸자에서 이미 delete된 포인터를 또 delete하기 때문에 크래시가 발생한다 (double free)
따라서 이러한 스마트 포인터를 직접 구현할때는 연산자 오버로딩으로 깊은 복사를 구현하거나 복사 생성자와 대입 연산자에 delete키워드를 추가하여 복사 자체를 막는 방법이 있다 (둘 다 권장하지 않음)
Move semantic의 기본 개념
복사 생성자와 복사 대입 연산자로 클래스의 멤버 포인터를 복사 하는 대신 포인터의 소유권을 원본 객체에서 대상 객체로 이동시키는게 바로 Move semantic의 기본 개념이다, 중요한 키워드는 소유권 이전이다
Move semantic의 기본 개념 확인을 위해 간단하게 구현하면 다음과 같다
template <typename T>
class MovePtr
{
public:
MovePtr(T* inPtr) : ptr{ inPtr }
{
}
MovePtr(MovePtr& other)
{
ptr = other.ptr;
other.ptr = nullptr;
}
MovePtr& operator=(MovePtr& other)
{
if (this == &other)
{
return *this;
}
delete ptr;
ptr = other.ptr;
other.ptr = nullptr;
return *this;
}
~MovePtr()
{
delete ptr;
}
T& operator*() const { return *ptr; }
T* operator->() const { return ptr; }
private:
T* ptr{};
};
class Foo
{
public:
Foo()
{
std::cout << "Foo created\n";
}
~Foo()
{
std::cout << "Foo destroyed\n";
}
private:
int value{};
};
int main()
{
MovePtr<Foo> amp{ new Foo() };
MovePtr<Foo> amp2{ nullptr };
amp2 = amp;
return 0;
}
복사 생성자와 대입 연산자를 구현하여 복사 대상의 멤버 포인터는 인자의 멤버 포인터로 이전하고 인자로 들어온 객체의 멤버 포인터는 nullptr로 만든다
이렇게 되면 포인터가 중복되지 않는다
위의 스마트포인터와 move semantic 구현은 단순히 이해를 위한 구현이다
R-value 참조
C++11 이전에는 단 한가지 유형의 참조만 존재했기 때문에 그냥 참조라고 불렀고 이를 현재는 lvalue참조라고 부른다
lvalue참조는 수정 가능한 lvalue로만 초기화 할 수 있다 (수정 불가능한 lvalue, rvalue는 불가능)
하지만 const lvalue참조는 수정 가능한 lvalue, 수정 불가능한 lvalue, rvalue 전부 가능하다 (단 수정이 불가능함)
int a = 10;
int& b = a; //ok
const int& c = 10; //const 객체이기 때문에 ok
const lvalue 참조는 함수의 인자로 사본을 만들지 않고 원본 전달이 가능하며 수정을 막아주기 때문에 굉장히 유용하게 사용된다
rvalue참조는 C++11에서 새롭게 추가되었다, rvalue참조는 rvalue로만 초기화되도록 설계되었다 (lvalue로는 초기화가 불가능하고 rvalue만 가능, 이는 const rvalue참조도 마찬가지이다)
lvalue참조는 &를 사용하지만 rvalue참조는 &&를 사용한다
int a{ 10 };
int& refa{ a };
int&& refb{ 10 };
rvalue에는 크게 유용한 두 가지 속성이 있다
Foo&& rrefFoo{ Foo{ 10 } };
rrefFoo.GetValue();
임시객체 rvalue인 Foo{ 10 }의 생명주기는 원래 해당 표현식에서 끝이지만 rvalue참조로 rrefFoo의 생명주기로 연장되어 밑에서 사용이 가능해진다
int&& rrefInt{ 10 };
rrefInt = 200; //literal값인 10으로부터 임시객체가 생성되어 rvalue참조 후 값을 수정하는 방식
일반적으로 rvalue참조는 함수 매개변수로서 많이 사용된다 (lvalue인자와 rvalue인자를 구분하는 함수 오버로딩 구현에 사용됨)
void fooFunc(const int& inValue)
{
std::cout << "lval: " << inValue << std::endl;
}
void fooFunc(int&& inValue)
{
std::cout << "rval: " << inValue << std::endl;
}
int main()
{
int lval{ 10 };
fooFunc(lval); //인자로 lvalue를 넘겼기 때문에 lval :함수가 호출됨
fooFunc(10); //인자로 rvalue를 넘겼기 때문에 rval :함수가 호출됨
return 0;
}
이는 move semantic에서 중요한 부분을 담당한다
rvalue참조에서 중요한 점은 rvalue참조 변수는 lvalue라는 사실이다
int&& rref{ 20 };
fooFunc(rref); //rvalue참조 변수는 lvalue이기 때문에 lval :함수가 호출된다
일반적으로 함수의 return타입을 lvalue참조로 하지 않는것처럼 거의 왠만하면 rvalue참조를 반환해서는 안된다
(함수내의 지역변수 및 임시객체는 함수가 반환되면 소멸되기 때문에 lvalue참조 반환과 마찬가지로 dangling참조 발생 가능성이 높다)
이동 생성자, 이동 대입
이전장에서 구현했던 깊은 복사를 수행하는 복사 생성자와 복사 대입 연산자를 사용하면 불필요한 객체 생성과 소멸이 발생하게 된다
C++은 move semantic을 위해 두 가지의 함수인 이동 생성자와 이동 대입 연산자를 정의한다
복사 생성자, 복사 대입 연산자는 한 객체의 복사본을 다른 객체에 만드는 것이라면 이동 생성자와 이동 대입 연산자는 리소스의 소유권을 한 객체에서 다른 객체로 옮기는것이다 (복사보다 비용이 적다)
깊은 복사를 수행하는 복사 생성자, 복사 대입 연산자와 구현은 비슷하지만 이동 생성자와 이동 대입 연산자는 const rvalue 참조 배개변수를 가진다
다음은 깊은 복사를 수행하는 복사 생성자, 복사 대입 연산자, 이동 생성자, 이동 대입 연산자를 이해하기 쉽게 구현한 코드이다
template <typename T>
class AutoPtr
{
public:
AutoPtr(T* inPtr = nullptr) :ptr{ inPtr }
{
}
~AutoPtr()
{
delete ptr;
}
//얕은 복사를 수행하는 이동 생성자 (리소스 소유권 이전)
AutoPtr(AutoPtr&& other) noexcept : ptr{ other.ptr }
{
std::cout << "Move Constructor called\n";
other.ptr = nullptr;
}
//얕은 복사를 수행하는 이동 대입 연산자 (리소스 소유권 이전)
AutoPtr& operator=(AutoPtr&& other) noexcept
{
std::cout << "Move Assignment Operator called\n";
if (&other == this)
{
return *this;
}
delete ptr;
ptr = other.ptr;
other.ptr = nullptr;
return *this;
}
//깊은 복사를 수행하는 복사 생성자
AutoPtr(const AutoPtr& other)
{
std::cout << "DeepCopy Constructor called\n";
ptr = new T;
*ptr = *(other.ptr);
}
//깊은 복사를 수행하는 복사 대입 연산자
AutoPtr& operator=(const AutoPtr& other)
{
std::cout << "DeepCopy Assignment Operator called\n";
if (this == &other)
{
return *this;
}
delete ptr;
ptr = new T;
*ptr = *(other.ptr);
return *this;
}
private:
T* ptr{ nullptr };
};
class Foo
{
public:
private:
};
AutoPtr<Foo> generateResource()
{
AutoPtr<Foo> res{ new Foo };
return res; // 이 반환 값은 이동 생성자를 호출 (컴파일러 최적화로 생략될 수도 있음)
//함수 내부에서 이름있는 local variable을 return할 때 NRVO로 인해 호출한 쪽 변수의 메모리에 직접 객체를 생성하도록 최적화 될 수 있다
}
int main()
{
AutoPtr<Foo> ptr1{ new Foo }; //기본 생성자
AutoPtr<Foo> ptr2{ ptr1 }; //복사 생성자
AutoPtr<Foo> ptr3{};
ptr3 = ptr1; //복사 대입 연산자
AutoPtr<Foo> ptr4{ generateResource() }; //이동 생성자가 호출되어야 하지만 NRVO로 인해 최적화되어 호출되지 않을 수 있음
AutoPtr<Foo> ptr5{};
ptr5 = generateResource(); //이동 대입 연산자
return 0;
}
이동 생성자, 이동 대입 연산자를 사용하면 깊은 복사를 수행하는 복사 생성자와 복사 대입 연산자를 사용하는것 보다 리소스 생성, 소멸을 줄일 수 있으며 같은 효과를 볼 수 있다 (중복 포인터 참조 방지)
이동 생성자, 이동 대입 연산자는 noexcept로 표시해야 한다 (컴파일러에게 이 함수는 예외를 던지지 않을것이라고 알려준다 -> 예외가 발생할리 없으니 이동해도 된다 (복사 방지))
이동 생성자와 이동 대입 연산자는 생성 혹은 대입을 위한 값이 rvalue일 때 호출된다 (literal, 임시객체)
복사 생성자와 복사 대입 연산자는 생성 혹은 대입을 위한 값이 lvalue이거나 인자가 rvalue인데 이동 생성자나 이동 대입 연산자가 정의되지 않은경우에 호출된다
암시적 이동 생성자 및 이동 대입 연산자
컴파일러는 프로그래머가 선언한 복사 생성자, 복사 대입 연산자, 이동 생성자, 이동 대입 연산자가 없으며 선언한 소멸자가 없을때 암시적 이동 생성자와 이동 대입 연산자를 생성한다
소멸자를 명시적으로 정의했다는건 특별하게 관리할 리소스가 있다는 의미가 되기 때문에 컴파일러가 자동으로 데이터를 이동 시키면 위험하겠다는 의미로 암시적 이동 생성자, 대입 연산자 생성을 막는다
이러한 암시적 이동 생성자, 이동 대입 연산자는 멤버별 이동을 수행하며 멤버가 적절한 이동 생성자나 이동 대입 연산자를 가지고 있다면 그것을 호출하고 그렇지 않다면 복사를 수행한다 (내부 멤버 포인터를 이동시키려면 이동 생성자와 이동 대입을 직접 정의해야 한다)
lvalue인 객체를 생성하거나 대입할 때 이동을 시키는건 그렇게 합리적이지 않을 수 있다 (나중에 해당 lvalue객체를 사용해야 할 수도 있기 때문에)
예를들여 a = b;라고 했을때 이동을 하게 되면 b의 멤버는 비워지게 되면서 나중에 b를 사용하게 될 경우가 고려되지 않게 된다
rvalue인 객체를 생성하거나 대입할 때 이동을 시키는건 위와 같은 문제가 발생하지 않는다 (어차피 임시 객체이기 때문에 비용이 큰 복사를 수행하는 대신 이동이 더 적절함)
그렇다면 어차피 임시객체인 rvalue객체를 이동 생성, 대입 할 때 왜 해당 rvalue객체 멤버 포인터를 nullptr로 할까?
이동 생성자, 대입 연산자에서 항상 인자로 받은 객체의 멤버 포인터를 nullptr로 변경하는데 이는 dangling pointer 방지를 하기 위함이다
예를들어 b.ptr이 a.ptr과 같은 주소를 가리키고 있을때 인자로 a가 들어오고 a.ptr을 nullptr처리를 하지 않고 a객체가 소멸될 때 a.ptr이 delete하게 처리된다면 b.ptr은 dangling pointer가 되게 된다
move semantic을 구현할 때 이동된 객체의 멤버를 nullptr로 만들어 올바르게 소멸되도록 하는게 중요하다 (nullptr은 delete하기에 안전하고 유효한 상태이다)
NRVO (Named Return Value Optimization), Copy Elision
다음과 같은 코드가 있다고 가정해보자
Resource generateResource()
{
Resource res;
return res;
}
int main()
{
Resource myRes{ generateResource() };
}
원래였으면 generateResource()는 res라는 lvalue 지역변수는 복사되어 return되고 myRes에 복사되게 된다, 하지만 NRVO에 따라 복사 과정이 생략된다
(어차피 지역변수라 소멸될 것인데 값 비싼 복사를 왜해? -> 임시 객체로 복사하지 말고 이동하자 라는 의미이다)
이동을 시켰기 때문에 껍데기만 남은 res는 결국 generateResource() 함수 종료 후 소멸되게 된다
최신 컴파일러에서는 복사/이동 생략으로 처리하는 경우도 있다 (Copy Elision), 이는 어차피 generateResource() 함수가 만드는 객체는 myRes에 들어가게 될 것이니 그냥 myRes 객체 메모리 공간에 generateResource() 함수를 이용하여 객체를 만들어 버리는 로직이다 (애초에 만들때부터 해당 메모리 공간에 만들어버린다)
이동과 복사가 통째로 생략된다
복사 비활성화
이동 생성자 및 이동 대입 연산자가 구현된 상태에서 복사 생성자와 복사 대입 연산자가 남아있다면 delete 처리로 해당 함수를 비활성화 하는것을 권장한다
//얕은 복사를 수행하는 이동 생성자 (리소스 소유권 이전)
AutoPtr(AutoPtr&& other) noexcept : ptr{ other.ptr }
{
std::cout << "Move Constructor called\n";
other.ptr = nullptr;
}
//얕은 복사를 수행하는 이동 대입 연산자 (리소스 소유권 이전)
AutoPtr& operator=(AutoPtr&& other) noexcept
{
std::cout << "Move Assignment Operator called\n";
if (&other == this)
{
return *this;
}
delete ptr;
ptr = other.ptr;
other.ptr = nullptr;
return *this;
}
//깊은 복사를 수행하는 복사 생성자
AutoPtr(const AutoPtr& other) = delete
{
std::cout << "DeepCopy Constructor called\n";
ptr = new T;
*ptr = *(other.ptr);
}
//깊은 복사를 수행하는 복사 대입 연산자
AutoPtr& operator=(const AutoPtr& other) = delete
{
std::cout << "DeepCopy Assignment Operator called\n";
if (this == &other)
{
return *this;
}
delete ptr;
ptr = new T;
*ptr = *(other.ptr);
return *this;
}
간단한 동적 템플릿 배열 구현
#include <cstddef> // std::size_t
#include <algorithm> // std::copy_n
template <typename T>
class DynamicArray
{
private:
T* m_array{};
int m_length{};
void alloc(int length)
{
m_array = new T[static_cast<std::size_t>(length)]; // 배열 할당
m_length = length;
}
public:
DynamicArray(int length)
{
alloc(length);
}
~DynamicArray()
{
delete[] m_array; // 배열 해제
}
// 복사 생성자 (깊은 복사)
DynamicArray(const DynamicArray& arr)
{
alloc(arr.m_length);
// arr.m_array에서 m_array로 m_length개의 요소를 복사
std::copy_n(arr.m_array, m_length, m_array);
}
// 복사 대입 연산자 (깊은 복사)
DynamicArray& operator=(const DynamicArray& arr)
{
if (&arr == this)
return *this;
delete[] m_array; // 기존 메모리 해제
alloc(arr.m_length); // 새 메모리 할당
// arr.m_array에서 m_array로 m_length개의 요소를 복사
std::copy_n(arr.m_array, m_length, m_array);
return *this;
}
int getLength() const { return m_length; }
T& operator[](int index) { return m_array[index]; }
const T& operator[](int index) const { return m_array[index]; }
};
복사 생성자, 복사 대입 연산자를 사용하는것 보다 이동 생성자, 이동 대입 연산자를 사용하면 대략 30%정도 더 빠른 결과를 얻을 수 있다
Rule of Five
특정 클래스 객체가 복사는 되지만 이동을 절대로 안되게 만들려면 다음과 같이 단순히 이동 생성자, 이동 대입 연산자 함수를 delete로 만들어 처리할 수 있다고 생각하기 쉽다
class Foo
{
public:
Foo()
{
}
Foo(int* InPtr) : ptrValue{ InPtr }
{
std::cout << "Foo()" << '\n';
}
~Foo()
{
std::cout << "~Foo()" << '\n';
}
Foo(const Foo& InFoo)
{
std::cout << "Copy Foo()" << '\n';
ptrValue = new int;
*ptrValue = *(InFoo.ptrValue);
}
Foo& operator=(Foo&& InFoo)
{
if (this == &InFoo)
{
return *this;
}
delete ptrValue;
ptrValue = new int;
*ptrValue = *(InFoo.ptrValue);
}
Foo(Foo&& InFoo) = delete;
Foo& operator=(Foo&& InFoo) = delete;
private:
int* ptrValue{ nullptr };
};
Foo GenerateFoo()
{
Foo f;
return f; //error!
}
하지만 위 코드는 컴파일이 불가능하다, 이유는 컴파일러가 GenerateFoo()를 할 때 가장 효율적인 방법으로 move를 선택하고 이동 생성자, 이동 대입 연산자가 있는지 확인한다
이때 함수는 존재하지만 delete이기 때문에 사용이 불가능하다, 이때 컴파일러는 차선으로 복사를 선택하지 않기 때문이다
(이동하려다가 delete이기 때문에 바로 포기, 복사 생성자와 복사 대입 연산자를 default로 처리해도 마찬가지이다)
따라서 복사만 가능한 클래스는 이동 생성자 및 이동 대입 연산자를 아예 선언하지 않는것이다
리소스를 직접 관리하는 클래스인 경우 Rule of Five를 지켜 복사 생성자, 복사 대입 연산자, 이동 생성자, 이동 대입 연산자, 소멸자중 하나라도 사용자 정의로 만들었다면 나머지도 모두 명시적으로 정의하는게 좋다
만약 Rule of Five를 지키지 않고 복사 생성자, 복사 대입 연산자, 소멸자만 구현하고 이동 생성자, 이동 대입 연산자는 구현하지 않으면 이동이 일어나야 할 최적의 상황에서 값 비싼 복사가 발생하여 오버헤드가 발생할 수 있고 std::move()를 통해 이동 시키려고 했을 때 이동 관련 함수가 없기 때문에 복사 관련 함수가 호출될 수 있다
단 Rule of zero를 지키는게 Rule of Five를 지키는것보다 권장된다, STL 스마트 포인터나 컨테이너를 사용해서 직접 사용자 정의 클래스에서 리소스 관리를 하지 않는 규칙이다
std::swap
std::swap은 두 객체의 값을 서로 맞바꾸는 STL함수이다 (utility 헤더)
간단하게 사용하면 다음과 같이 사용이 가능하다
int a{ 10 };
int b{ 20 };
std::swap(a, b); //a에 20, b에 10
std::swap은 move semantic을 사용하여 값을 교환한다, 내부를 들여다보면 다음과 같다
template <typename T>
void swap(T& a, T& b)
{
T temp = std::move(a);
a = std::move(b);
b = std::move(temp);
}
temp변수에 a를 move하고 a에 b를 move, b에 temp에 있던 a값이 move되는 로직이다
따라서 복잡한 객체에서 std::swap은 move semantic에 의한 교환이 발생하기 때문에 비용이 많이 발생하는 복사보다 더 효율적인 교환이 가능하다 (std::swap을 사용하려면 이동 생성자, 이동 대입 연산자 둘 다 정의되어 있어야 한다)
이때 주의할 점은 프로그래머가 정의한 이동 생성자, 이동 대입 연산자에 std::swap을 사용하면 무한 재귀가 발생할 수 있다
std::swap 내부에서도 move semantic이 호출되면서 다시 이동 생성자, 이동 대입 연산자가 호출되고 또 다시 std::swap이 걸리게 되면서 move semantic이 호출... 이러한 과정이 무한 반복되게 될 수 있다
Foo(Foo&& InFoo) noexcept
{
std::swap(*this, InFoo); //무한 재귀 가능
std::swap(a, InFoo.a); //이렇게 swap하는게 더 권장됨
}
std::swap(a, InFoo.a)를 하게 되면 this객체의 a값에 InFoo.a가 들어가게 되고 InFoo.a에는 a가 들어가게 된다, 이때 InFoo는 임시객체이기 때문에 소멸되므로 null인 값을 갖게되어도 전혀 상관이 없다