17. 생성, 소멸, 복사와 이동

17.2 생성자와 소멸자

17.2.1 생성자와 불변속성

  • 자신의 클래스와 동일한 이름을 가진 멤버를 생성자라고 부른다.

  • 생성자 선언은 매개변수는 지정하지만, 리턴타입은 지정하지 않는다.

class Vector {
public : 
	Vector(int s) ;
	// ...
}
  • 생성자의 역할은 소속된 클래스의 개체를 초기화하는 것이다.

  • 초기화는 클래스 불변속성(class invariant) 를 구축해야 한다.

    • 불변속성 : 멤버 함수가(클라이언트에 의해) 호출될 때마다 클래스 인스턴스가 항상 만족해야 하는 조건의 의미한다.

  • 아래 예제에서는 불변속성은 주석으로 설명된다.

class Vector {
public : 
	Vector(int s);
	// ...
private : 
	double* elem;   // elem 은 sz 개의 double 을 가진 배열을 가리킨다. 
	int sz;         // sz 는 음수가 아니다. 
};
  • 생성자는 불변 조건을 참으로 만들어야 한다.

    • 아래 생성자는 불변속성을 구축하려고 시도하며, 그렇게 할 수 없을 경우 예외를 던진다.

    • 생성자가 불변속성을 구축할 수 없다면 아무런 개체가 생성되지 않고, 생성자는 아무런 자원의 누출이 없도록 보장해야 한다.

    • 자원이란 획득해야 하고 볼 일이 끝나면 결국에 가서는 돌려줘야 하는 것을 말한다.

  • 불변속성을 지켜야 하는 이유는 다음과 같다. → 평균적으로 불변속성을 정의하는 노력은 일을 덜어준다.

    • 클래스의 동작을 명확히 하기 위해서

    • 멤버 함수의 정의를 단순화하기 위해서

    • 클래스 자원 관리를 명확하게 하기 위해서

    • 클래스 문서화를 정확히 하기 위해서(?)

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=yx==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