17. 생성, 소멸, 복사와 이동
17.2 생성자와 소멸자
17.2.1 생성자와 불변속성
자신의 클래스와 동일한 이름을 가진 멤버를 생성자라고 부른다.
생성자 선언은 매개변수는 지정하지만, 리턴타입은 지정하지 않는다.
생성자의 역할은 소속된 클래스의 개체를 초기화하는 것이다.
초기화는 클래스 불변속성(class invariant) 를 구축해야 한다.
불변속성 : 멤버 함수가(클라이언트에 의해) 호출될 때마다 클래스 인스턴스가 항상 만족해야 하는 조건의 의미한다.
아래 예제에서는 불변속성은 주석으로 설명된다.
생성자는 불변 조건을 참으로 만들어야 한다.
아래 생성자는 불변속성을 구축하려고 시도하며, 그렇게 할 수 없을 경우 예외를 던진다.
생성자가 불변속성을 구축할 수 없다면 아무런 개체가 생성되지 않고, 생성자는 아무런 자원의 누출이 없도록 보장해야 한다.
자원이란 획득해야 하고 볼 일이 끝나면 결국에 가서는 돌려줘야 하는 것을 말한다.
불변속성을 지켜야 하는 이유는 다음과 같다. → 평균적으로 불변속성을 정의하는 노력은 일을 덜어준다.
클래스의 동작을 명확히 하기 위해서
멤버 함수의 정의를 단순화하기 위해서
클래스 자원 관리를 명확하게 하기 위해서
클래스 문서화를 정확히 하기 위해서(?)
17.2.2 소멸자와 자원
생성자는 개체 생성시 개체를 초기화 한다. → 바꿔 말하면 멤버 함수가 동작할 수 있는 환경을 생성하는 것이다.
개체 생성 시 때때로 자원 획득이 수반된다. → 파일, 잠금, 약간의 메모리 등 .. → 해당 자원은 사용 후 반드시 자원이 해제되어야 한다.
개체가 소멸될 때 호출되는 것은 소멸자로
~클래스 이름
의 형태이다.클래스는 단 하나의 소멸자만을 가질 수 있다. (매개변수를 받지 않는다)
소멸자는 개체가 스택 범위를 벗어나거나, 힙 영역의 자원이 해제될 때 암시적으로 호출된다.
아래 예제를 살펴보자
Vector v1 은 f() 에서 빠져 나오자마자 소멸된다.
또한 힙 영역의 new 를 이용해 f() 에 의해 생성된 Vector 는 delete 호출에 의해 소멸된다.
만약 생성자가 충분한 메모리를 획득하지 못한다면 어떻게 될까?
해당 경우에는 std::bad_alloc 예외가 new 에 의해서 던져지고, 예외 처리 메커니즘이 적절한 소멸자를 호출해서 획득된 모든 메모리를 비우게 만든다.
생성자, 소멸자를 기반으로 한 이런 스타일의 자원 관리는 자원 획득의 초기화(Resource Acqusition Is Initialization) 이라고 부른다. (간단히 RAII 이라고 부른다)
클래스의 소멸자를 정의하지 않는다면, 기본 소멸자를 컴파일러가 정의해준다.
17.2.3 기반 클래스와 멤버 소멸자
생성자와 소멸자는 클래스 계층 구조와 연관되어 작동된다.
생성자는 부모 클래스 부터 호출되고
소멸자는 자식 클래스부터 호출된다.
17.2.4 생성자와 소멸자의 호출
소멸자는 유효 범위를 빠져나갈 때 delete 에 의해 암시적으로 실행된다.
소멸자의 명시적 호출은 불필요할 뿐 아니라, 그렇게 하면 개체 소멸 흐름을 어렵게 만들 것이다.
하지만 드물게 소멸자가 명시적으로 호출되어야 하는 때가 있다. (자신이 그 안에서 커지거나 줄어들 수 있는 메모리 풀을 유지해야 하는 Vector 컨테이너의 경우)
아래와 같이 생성자를 활용하는 것을 ‘위치 지정 new’ 라고 부른다.
반대로 하나의 원소를 삭제하고 싶다면, 컨테이너는 소멸자를 호출해야 한다.
이런 표기법은 정상적으로 소멸되는 개체에 대해서는 절대로 사용되어서는 안된다.
개체의 소멸을 방지하고 싶다면, 소멸자를 private 로 선언하면 된다.
17.2.5 virtual 소멸자
virtual 소멸자가 필요한 이유는 대개 기반 클래스가 제공하는 인터페이스를 통해 조작되는 개체는 해당 인터페이스를 통해 delete 처리 되기 때문이다.
일반적으로 내가 만드는 클래스를 누군가 상속받아 사용할 수도 있기 때문에, virtual 소멸자는 기본적으로 정의해주자.
17.3 클래스 개체 초기화
17.3.1 생성자를 정의하지 않은 경우의 초기화
기본 제공 타입에 대해서는 생성자를 정의할 수 없지만, 적절한 타입의 값으로 초기화 할 수 있다.
사용자 정의 타입(클래스, 구조체)에 대해서는 컴파일러가 기본적으로 생성해주는 생성자를 호출해 개체를 초기화 할 수 있다.
멤버 단위 초기화
해당 생성자는
{}
로 호출한 경우에만, 사용 가능하며 멤버 변수가 public 로 선언되어 있어야 한다. (캡슐화 문제때문에, 아마 잘 사용되지는 않을 것 같다)
복사 초기화
기본 초기화
기본 생성자를 사용한 초기화의 경우
{}
로 초기화 되며, 멤버 변수가 타입의 기본값으로 초기화 된다.
생성자를
{}
로 호출하지 않을 시, 초기화를 하지 않는 등 예기치 않는 방식으로 동작할 수 있기 때문에,{}
사용을 권장한다. →()
로 호출하는 경우, 함수 호출과 모양이 동일하기 때문에, 함수를 호출할 수도 있다.
17.3.2 생성자를 사용한 초기화
사용자가 정의한 생성자를 통해서 개체의 초기화가 이루어 질 수도 있다.
생성자는 종종 해당 클래스에 대한 불변 속성을 구축하거나 그것을 위해 필요한 자원들을 획득하기 위해서 사용된다. (당연한 말씀 ..)
사용자가 생성자를 정의하게 되면 컴파일러는 기본 생성자를 만들지 않는다.
그렇지만 복사 생성자는 사라지지 않기 때문에 복사 될 수 있다. (얕은 복사)
추가적으로
{}
초기화를 사용하면 변수 초기화부터 클래스 멤버 초기화까지 모든 초기화 상황에서 동일한 초기화 규칙이 적용된다.=
와()
를 사용한 초기화는 균일하지 않다.다행히
=
와()
를 사용한 초기화는 균일하지도 않고,그것에 대한 예제가 모호하다! (때문에, 오류를 알아차리기 쉽다)
또한 통상적인 오버로딩 해결 규칙이 적용된다.
17.3.2.1 생성자에 의한 초기화
{}
를 사용하게 되면, 멤버 단위 초기화 or 초기화 식 리스트 초기화를 사용할 수 있게 된다.{}
초기화의 사용은 C++11 에 와서야 가능하게 되었으므로 과거 코드는()
와=
를 많이 사용한다.
vector 과 같이 생성자 호출이 어려운 경우를 제외하고는,
()
표기를 사용해야 할 논리적인 이유를 찾기가 어렵다!
17.3.3 기본 생성자
매개 변수 없이 호출될 수 있는 생성자를 기본 생성자(default constructor) 라고 부른다.
기본 생성자는 아무 인자도 지정되지 않거나, 빈 초기화 식이 제공될 경우 사용된다.
기본 인자는 인자를 받아들이는 생성자를 기본 생성자로 만들 수 있다.
17.3.4 초기화 식 리스트 생성자
std::initializer_list
타입의 단일 인자를 받아들이는 생성자를 초기화 식 리스트 생성자(initializer-list constructor) 라고 부른다.표준 라이브러리 컨테이너(vector, map) 는 초기화 식 리스트 생성자를 가진다.
{}
리스트를 받아들이기 위해 메커니즘은std::initializer_list<T>
타입의 인자를 받아들이는 함수다.
17.3.4.1 initializer_list 생성자 디테일
하나의 클래스에서 여러개의 생성자가 있을 경우 통상적인 오버로딩 규칙에 의해 인자 집합에 맞는 생성자를 사용한다.
생성자 선택에 있어서는 기본 생성자, 초기화 식 리스트 생성자가 우선권을 갖는다.
만약 기본 생성자, 초기화 식 리스트 생성자 모두 호출 가능하다면 기본 생성자를 우선적으로 사용한다. → 빈 리스트를 가지고 기본 생성자 외 다른 생성자를 호출하는 것 자체가 말이 안된다.
초기화 식 리스트 생성자와 일반적인 생성자 모두 호출 가능하다면 초기화 식 리스트 생성자를 우선적으로 사용한다.
만약 한개 또는 두개의 정수 인자를 받아들이는 생성자를 호출하고 싶다면
()
표기법을 사용해야 한다. → initailizer_list 인자를 받는 생성자가 있다면, 컴파일러가 최선을 다해서 해당 생성자와 매칭시키려고 노력한다는 의미이다.
17.3.4.2 initializer_list 의 사용
initializer_list<T>
인자를 가진 함수는 멤버 함수 begin(), end(), size() 를 이용해서 해당 인자에 접근할 수 있다. → initializer_list<T> 는 값으로 전달된다.
initializer_list 의 원소는 불변적이다. 그들의 값을 변경하려는 시도는 꿈도 꾸지 말아라!
17.3.4.3 직접적 초기화와 복사 초기화
컨테이너 생성자 중 일부는 명시적일 수 있고, 일부는 아닐 수 있다.
vector
가 그런 예 중 하나이다. →std::vector<int>(int)
는 explicit 이지만,std::vector<int>(initializer_list<int>)
는 그렇지 않다.아래는 () 를 사용해 초기화를 하는 예제이다.
() 를 {} 로 대체함으로써 결과는 달라진다.
17.4 멤버 및 기반 클래스 초기화
17.4.1 멤버 초기화
멤버의 생성자를 위한 인자는 멤버 초기화 식 리스트에서 지정되는데, 멤버 초기화 식 리스트는 소속 클래스의 생성자에 대한 정의가 들어 있다.
멤버 초기화는 명시적으로 사용하는 것이 가독성 측면에서 이점이 있다.
17.4.1.1 멤버 초기화와 대입
멤버 변수를 초기화 함에 있어서 2가지 방법이 있다.
초기화 리스트를 사용한다. → 생성과 동시에 초기화가 이루어 진다.
생성자 범위에서 대입을 한다. → 생성 후 초기화가 이루어진다. → 타입이 참조자 타입이거나 const 타입이라면 생성과 동시에 초기화가 이루어져야 하므로 해당 방법은 컴파일 오류가 발생한다.
생성자에서 멤버 초기화 시 초기화 리스트를 사용하는 것이 성능적으로도 이점이 있다. → 초기화 리스트를 사용하면 초기화와 대입이 한번에 이루어진다.
17.4.2 기반 클래스 초기화 식
클래스 상속 시 기반 클래스가 초기화 식을 요구하면 해당 초기화 식은 생성자 내의 기반 클래스 초기화 식으로써 제공되어야 한다. → 그렇게 하고 싶다면 기본 생성을 명시적으로 지정할 수 있다.
멤버에서와 마찬가지로 초기화 순서는 선언 순서이고, 그 순서로 초기화 식을 지정하는 것을 추천한다.
17.4.3 위임 생성자
두 개의 생성자가 똑같은 동작을 수행하게 하고 싶다면 공통된 동작을 수행할 함수를 정의할 수 있다.
또는 생성자를 또 따른 생성자의 관점에서 정의하는 것이다. → 이러한 생성자는 위임 생성자(delegate constructor) 라고 불린다.
개체는 생성자가 완료될 때까지는 생성되지 않은 것으로 간주된다. → 위임 생성자를 사용할 때는 위임된 생성자가 완료되면 개체가 생성될 것으로 간주된다.
17.4.4 클래스 내 초기화 식
클래스 선언 안에서 비 static 데이터 멤버에 대한 초기화 식을 지정할 수 있다. → 구문 분석 및 이름 탐색과 관련된 상당히 모호한 기술적인 이유 때문에,
{}
과=
초기화 식은 클래스 내 멤버 초기화 식에서 사용될 수 있지만,()
표기법은 사용될 수 없다. (함수 호출과 혼동된다)
초기화 리스트를 통해서도 멤버 변수를 초기화 할 수 있는데 이 때 중복되는 초기화 값이 있다면 멤버 변수 선언과 동시에 초기화 해 공통값이 명확하게 들어날 수 있도록 사용할 수 있다.
17.4.5 static 멤버 초기화
static 클래스 멤버는 각 클래스 개체의 일부가 아니기에 클래스가 메모리에 로드 될 때 초기화 된다. →
static
멤버는 클래스 정의 외부에서 초기화해야 합니다.
하지만 몇가지 특수한 상황에서는 클래스 선언에서 static 멤버를 초기화 할 수 있다.
static const
정수형 멤버 변수는 클래스 정의 내부에서 초기화 할 수 있습니다.
C++11부터는
constexpr
키워드를 사용하여 클래스 내부에서 정적 멤버 변수를 초기화할 수 있습니다.
17.5 복사와 이동
17.5.1 복사
클래스
X
에 대한 복사는 두 개의 연산으로 정의된다.복사 생성자
X(const X&)
복사 대입연산자 X&
operator=(const X&)
복사 생성자는 개체를 변경하지 않고 개체의 사본을 만들어주는 것이다. 마찬가지로 복사 대입연산자도 동일하게 동작한다. → 복사 생성자와 복사 대입연산자는 보통 모든 비 static 멤버를 복사해야 한다.
복사 생성자와 복사 대입연산자의 차이는 다음과 같다.
복사 생성자 : 초기화 되지 않은 메모리를 초기화 한다.
복사 대입 연산자 : 이미 생성되어 자원을 소유하고 있을 수 있는 개체를 다룬다.
17.5.1.1 기본 생성자에 대해 조심할 사항
복사 연산을 작성할 때는 반드시 모든 기반 클래스와 멤버를 복사해야 한다.
아래의 경우
s2
를 복사하는 것을 까먹었기 때문에, 기본 초기화가 될 것이다. → 대규모의 클래스의 경우 생각보다 이런 경우가 많아질 수 있다.
17.5.1.2 기반 클래스의 복사
‘복사’ 라는 관점에서는 기반 클래스는 하나의 멤버일 뿐이다. 즉 파생 클래스의 개체를 복사하려면 그것의 기반 클래스를 복사해야 한다.
17.5.1.3 복사의 의미
올바른 복사 연산의 조건을 충족하기 위해서는 아래 두가지 기준을 통과해야 한다. → 매우 당연한 이야기지만, 기본적인 조건들을 만족해야만 단단한 프로그램을 만들 수 있다.
등가성(equivalence) : x=y 후에 x, y 를 사용할 때 동일한 결과를 산출해야 한다. →
x=y
는x==y
를 뜻할 수 있어야 한다.독립성(independence) : x=y 후에 , x 에 대한 연산은 암시적으로 y의 상태를 변경하지 않아야 한다.
얕은 복사의 경우 두 개체를 공유 상태에 놓이게 하며, 독립성을 해친다. → 엄청난 혼돈과 오류의 가능성을 내포하고 있다.
깊은 복사를 통해서 개체의 전체 상태를 복사할 수 있지만, 이는 필요하지 않은 복사가 이루어 질 수 있기 때문에, 이동 연산을 사용하기도 한다.
이동 연산은 복잡성을 더하지 않고, 복사를 최소화 해준다!
17.5.2 이동
a 의 값을 b 로 옮기는 전통적인 방법은 복사하는 것이다.
하지만 논리적인 관점에서 보게 되면 그렇지 않다.
아래 swap 예제를 살펴보자
tmp 를 초기화 한 후, a 값의 두 개의 사본이 나온다.
a 를 초기화 한 후 b 값의 두 개의 사본이 나온다.
b 를 초기화 한 후 tmp 값의 두 개의 사본이 나온다.
아래 예제를 살펴보자
s1 이 1000 개의 문자를 갖고 있다면 어떻게 될까?
vs1 이 1000 개의 원소를 갖고 있다면 어떻게 될까?
m1 이 double 로 이뤄진 1000*1000 행렬이라면 어떻게 될까? → 복사하는 비용이 무쟈게 크다!!
swap 은 string, vector 에 대해서 이런 오버헤드를 피할 수 있도록 신중하게 설계되었다.
위 예제에서 문제는 우리는 아무것도 복사하기를 원치 않는다는 점에 있다. → 단지 값의 쌍을 교환하고 싶었을 뿐이다.
이러한 문제를 해결하기 위한 방법이 이동 연산이고, 이동 연산은 소유권을 이전한다.
사용자가 복사와 관련된 성능적 문제를 피할 수 있게 C++ 은 이동의 개념을 직접적으로 지원한다.
이를 위해 이동 생성자와, 이동 대입을 정의할 수 있다.
이동 연산의 바탕이 되는 것은 좌변 값과 우변 값을 분리해서 처리하자는 것이다.
복사 생성자와 복사 대입 연산자는 좌변 값을 사용하는 반면,
이동 생성자와 이동 대입 연산자는 우변 값을 사용한다.
다음 예제를 살펴보자
이동 생성자는 기존 값을 기반으로 멤버 변수를 초기화 하고 기존 값을 기본값으로 초기화 해준다.
이동 대입 연산자는 그냥 swap 을 통해 처리할 수 있다. → 기존 값이 곧 소멸 예정이라는 전재하에 swap 으로 처리할 수 있다.
이동 생성자와 이동 대입 연산자는 const 가 아닌 값을 매개 변수로 받아들인다. → 이동이라는 개념을 완성하기 위해서는 기존 값을 기본값으로 초기화해야 한다.
복사 연산자는 예외를 던질 가능성이 있지만, 복사 생성자는 예외를 던질 가능성이 거의 없다.
복사 연산자는 힙 메모리 할당 시 메모리 할당 실패(
std::bad_alloc
)와 같은 예외가 발생할 수 있다.이동 연산자는 리소스를 단순히 포인터나 참조를 통해 이전하는 작업이므로 예외를 던질 가능성이 적다. → 때문에 noexcept 키워드를 붙여준다. → 복사 연산자에 비해서 자원 컨트롤이 매우 간단하고 효율적이다.
컴파일러는 언제 복사 연산이 아닌, 이동 연산을 사용할 수 있는지 알까?
이를 위해서 몇가지 규칙이 있지만, 대부분
std::move()
함수를 사용해 명시적으로 알려주어야 한다.std::move(a)
는 “a 에 대한 우변값 참조자를 주세요.” 라는 의미이다.
17.6 기본 연산의 생성
클래스를 생성하게 되면 컴파일러는 암시적으로 다음의 기본 연산들을 제공한다.
기본 생성자
복사 생성자
복사 대인
이동 생성자
이동 대입
소멸자
17.6.1 명시적 기본 설정
개발자가 생성하지 않더라도 암시적으로 기본 연산들은 생성하지만, 명시적으로 보고싶은 경우도 있다.
default
를 통해서 명시적으로 선언해 줄 수 있다.
17.6.3 기본 연산의 사용
이번 절에서는 복사, 이동, 소멸자가 어떻게 논리적으로 연결되어 있는가를 살펴보자
17.6.3.1 기본 생성자
일반적으로 인자를 받는 생성자를 정의하면, 컴파일러가 기본 생성자를 생성하지 않는다.
그럼에도, 기본 생성자를 컴파일러가 생성해주기를 원한다면
default
키워드를 통해 선언할 수 있다.
17.6.3.2 불변속성 유지
대부분의 클래스는 불변속성을 가진다. → 불변속성 : 클래스 개체가 항상 참으로 유지되는 조건
그렇다면 복사와 이동 연산에서 그것이 유지돼야 하고, 소멸자는 관련된 자원을 모두 비울 수 있어야 할 것이다.
하지만, 컴파일러가 모든 경우마다, 프로그래머가 무엇을 불변속성이라고 생각하는지는 알 수 없는 노릇이다.
억지로 꾸민듯한 아래 예제를 보자
프로그래머가 불변속성을 주석에서 서술했지만, 컴파일러는 주석을 읽지 못한다.
게다가 프로그래머는 해당 불변속성이 어떻게 구축되고 유지될 수 있는지에 대한 힌트를 남기지 않았다.
때문에 불변속성을 가지기 위한 규칙은 다음과 같다.
생성자에서 불변속성을 구축한다.(필요한 경우 자원 획득까지 포함해서)
복사와 이동 연산에 대해 불변속성을 유지한다.
소멸자에서 필요한 모든 마무리를 처리한다.(필요한 경우 자원 해제를 포함해서)
17.6.4 delete 생성자
컴파일러가 생성자를 생성하지 않도록 delete 선언 할수도 있다.
다음의 코드를 보자
Last updated