본문 바로가기
Programming/C++

C++ 언어 기초 (7) - 디폴트/복사/이동 생성자

by 롱일스 2020. 8. 28.
반응형

▣ 디폴트 생성자

 ●  디폴트 생성자(default constructor)란?

  ▷ 매개변수가 없는 생성자, 또는 모든 매개변수에 디폴트 인수가 지정된 생성자

  ▷ 클래스 선언 시 생성자를 선언 안하면 컴파일러가 알아서 디폴트 생성자를 정의해준다.

  ▷ 생성자를 하나라도 선언하면 컴파일러는 묵시적으로 디폴트 생성자 정의하지 않는다.

 

 ●  묵시적 디폴트 생성자

  ▷ 클래스 내에 생성자를 따로 선언해주지 않아도 컴파일러가 알아서 Counter(){ } 과 같이 묵시적 디폴트 생성자를 만들어준다. 클래스 외부에서 객체를 만들 때도 Counter cnt; 와 같이 매개변수 없이 만들 수 있다. 

/* Counter.h */
class Counter{
	int value;
public:
	//Counter() {} //생성자를 정의하지 않으면 이렇게 컴파일러가 만들어준다.
	void reset() {value = 0;}
	void count() {
		value = value < maxValue ? value + 1 : 0;
	}
	.....
};
int main()
{
  Counter cnt;
  ....
}

  ▷ 이 외에도 아래와 같이 객체 배열을 선언할 수 있다.

int main()
{
  Counter cntArr[4];       //객체 4개 생성
  Counter *pt = new Counter[10];   //Counter 객체 10개 생성 
  ....
}

 

 ●  디폴트 생성자가 없는 클래스

/* CounterM.h */
class CounterM{
	const int maxValue;
	int value;
public:
	CounterM(int mVal): maxValue{mVal}, value{0} {} //생성자 초기화 리스트
	void reset() {value = 0;}
	void count() {
		value = value < maxValue ? value + 1 : 0;
	}
	.....
};
  •   클래스 내에서 생성자를 정의해주면 컴파일러가 디폴트 생성자를 만들지 않는다. 
  •   만약 위의 코드에서 CounterM 생성자를 선언하는 문장이 없어지면 다른 소스 파일에서 위 클래스 객체를 선언할 때, 디폴트 생성자를 만들 것이다.

여기서 잠시 생성자 초기화 리스트에 대해 복습하고 지나가자.

2020/08/27 - [Programming/C++] - [초급] C++언어 기초 (6) - 클래스와 객체

  •  생성자를 선언할 때 초기화 리스트를 이용하면 디폴트로 데이터멤버를 초기화할 수 있다.
  •  위 코드에서는 생성자가 호출될 때 자동으로 maxValue = mVal로 value =0으로 초기화한다.

 

 ●  초기화 리스트를 사용하는 이유

  ▷ 잠깐 초기화 리스트를 사용하는 이유를 설명해보려 한다. 아래 코드를 한 번 보자.

class CounterM{
	const int maxValue;
	int value;
public:
    CounterM(int mVal) //생성자
    {
      maxValue = mVal;
      value = 0;
    } 
    
	void reset() {value = 0;}
	void count() {
		value = value < maxValue ? value + 1 : 0;
	}
	.....
};
  •  만약 위와 같이 생성자를 선언하면 오류가 발생한다.
  •  maxValue와 value가 private한 데이터멤버이기 때문에 생성자 내에서 대입 명령어를 쓰면 오류가 발생한다. 그래서 초기화 리스트를 사용하는 것이 좋다.

 

 ●  디폴트 생성자가 없는 클래스 객체를 사용할 때 

  ▷ 위에서 정의한 CounterM 클래스의 객체를 생성할 때 디폴트 생성자가 없도록 클래스 내에서 생성자를 따로 정의해줬기 때문에 CounterM cnt2;처럼 매개변수 없이 객체를 만드는 행위는 불가능하다. 

#include "CounterM.h"
int main()
{
  CounterM cnt1(999);
  CounterM cnt2; //에러!!!!
  ....
}

  ▷ 객체 배열 선언 시 주의점

  • 아래 경우도 디폴트 생성자가 없기 때문에 매개변수로 초깃값을 지정해줘야 객체 생성에 오류가 없다.
  • 객체 3개 만들었으면 매개변수도 3개 정해줘야지!
int main()
{
   CounterM cntMArr1[3];  //에러!!!
   CounterM cntMArr2[3] = {CounterM(9), CounterM(99), CounterM(999)};  //문제없음!
   CounterM *pt = new CounterM[10];  //에러!!
   ....
}

 


▣ 복사 생성자

 ●  복사 생성자(copy constructor)란?

  ▷ 같은 클래스의 객체를 복사해서 객체를 만드는 생성자

  ▷ 묵시적 복사 생성자: 객체의 데이터 멤버들을 그대로 복사하여 객체를 만들도록 묵시적으로 정의된 복사 생성자

 

 ●  명시적으로 복사 생성자를 정의하는 형식

 ▷ 복사 생성자가 현재 없기 때문에 값 매개변수로 받을 수 없고, 반드시 참조형으로 객체를 받아야 한다.

  const를 통해 원본의 값이 변경되지 않도록 한다.

class ClassName {
   ...
public:
   /* 복사 생성자 시작 */
   ClassName(const ClassName& obj){
     ...  // 생성되는 객체에 obj를 복사하는 자리
   }
   /* 복사 생성자 끝 */
   ...
};

 

 ●  묵시적 복사 생성자

 복사 생성자를 선언해주지 않으면 역시 컴파일러가 알아서 복사 생성자를 만들어준다.

class CounterM{
	const int maxValue;
	int value;
public:
	CounterM(int mVal): maxValue{mVal}, value{0} {}
    
	void reset() {value = 0;}
	.....
};

아래 세 가지 객체 생성 문장은 다 같은 의미를 가진다. 주석의 설명을 참고하면 좋다.

int main()
{
  ....
  CounterM cnt4(99);
  CounterM cnt5(cnt4); 
  //counterM 클래스의 객체인 cnt4를 가지고 counterM 클래스의 객체인 cnt5를 만드는 것인데 
  //이런 생성자는 정의한 적이 없지만 컴파일러가 묵시적으로 복사 생성자를 만들어준다.
  
  CounterM cnt6 = cnt4; 
  //대입하는 게 아니라 cnt6를 cnt4로 초기화하라는 의미로 복사 생성자가 이용된다.
  ....
}

복사 생성자를 선언해주지 않았지만 컴파일러가 클래스에 아래와 같이 복사 생성자를 묵시적으로 만들어준다.

class CounterM{
	const int maxValue;
	int value;
public:
	CounterM(int mVal) : maxValue{mVal}, value{0} {}
    
    //묵시적 복사 생성자 made by 컴파일러
    CounterM(const CounterM& c) : maxValue{c.maxValue}, value{c.value} {}
    
	void reset() {value = 0;}
	.....
};

 

 ● 얕은 복사의 문제점 - VecF 클래스

 ▷ VecF 클래스

  • 벡터 객체를 만들 수 있는 클래스로 VecF 객체는 저장할 수 있는 float 값의 수를 인수로 지정하여 생성된다. 
  • 저장할 값의 배열이 제공될 경우 그 값으로 초기화한다.
  • 인수로 전달된 VecF 객체와 덧셈한 결과를 반환할 수 있다.
  • 객체에 저장된 벡터를 출력할 수 있다.

 

메소드 설명
VecF(int d, float* a) 생성자 (d: 개수, a: 배열)
~VecF() 소멸자
VecF add(const VecF& fv) fv와 덧셈을 한 결과를 반환한다.
void print() 벡터를 출력한다.
속성 설명
int n 벡터의 크기를 저장한다.
float *arr 벡터의 저장공간 포인터

 

 ▷ VecF.h

#include <iostream>
#include <cstring>
using namespace std;

class VecF {
	int n;
	float *arr;
public:
	VecF(int d, const float* a=nullptr) : n{ d } {
	 arr = new float[d];
	 if (a) memcpy(arr, a, sizeof(float) *n); 
      //a가 nullptr이 아니고 매개변수로 a의 값을 받았을 때 데이터를 복사한다.
      //memcpy를 사용하기 위해 <cstring>을 #include함.
      //a를 arr로 복사하라. sizeof(float) = 4 크기만큼만 복사하라. 
	}
    
    ~VecF() {
     delete[] arr;
	}
	
	VecF add(const VecF& fv) const {
	  VecF tmp(n);  //벡터의 덧셈 결과를 저장할 임시 객체
	  for (int i = 0; i < n; i++)
	    tmp.arr[i] = arr[i] + fv.arr[i];
	  return tmp;   //덧셈 결과 반환
	}
	
	void print() const{
	  cout << "[ ";
	  for (int i=0; i < n; i++)
	    cout << arr[i] << " ";
	  cout << "]";
	}
};
  • a에 디폴트 인수로 nullptr을 설정해줘서 a를 인수로 받지 않는 경우 nullptr로 설정되고 a를 인수로 받는 경우에는 받은 그 값으로 초기화된다.
  • if (a) 는 a를 인수로 받은 경우 null이 아니기 때문에 true가 되어 memcpy 함수를 실행한다.
  • a를 인수로 받지 않아서 nullptr로 설정된 경우는 if(a)가 false가 되어 memcpy 함수를 실행하지 않는다.
  • memcpy(arr, a, sizeof(float) * n); 에서는 a를 arr로 복사하고, 4byte * n 크기만큼 복사한다.
  • VecF add(...) const {... 에서 add 함수는 fv를 참조형으로 받고 fv의 값을 수정할 필요가 없기 때문에 const로 선언했고 객체 자기 자신의 값을 바꿀 필요가 없기 때문에 함수 뒤에서 const를 적어줬다.
  • VecF tmp(n)을 통해서 n개의 데이터를 저장할 수 있는 tmp라는 객체를 만들어 준다. 위에서 정의한 VecF 생성자에 의해 메모리만 만들고 a를 초기화 하지는 않는다. 왜냐하면 어차피 tmp.arr에는 더한 값들을 넣을 거기 때문에 초기화가 필요하지 않다.
  • return 명령을 통해 더한 값인 tmp를 함수가 반환한다.
  • print() 멤버함수를 통해 arr의 값들을 모두 출력한다.

 

 ▷ VFMain1.cpp

#include <iostream>
using namespace std;
#include "VecF.h"

int main()
{
	float a[3] = {1,2,3};
	VecF v1(3,a);   //1,2,3을 저장하는 벡터 
	VecF v2(v1);    //v1을 복사하여 v2를 만든다 
	v1.print();
	cout << endl;
	v2.print();
	cout << endl;
	return 0;
	
}
  • VecF v2(v1); 에서 복사 동작이 필요한데, 우린 VecF.h에서 복사 생성자를 만들지 않았기 때문에 컴파일러가 묵시적으로 복사 생성자를 만들어주고 그 생성자에 의해 복사를 수행한다.

※빌드 했을 때 nullptr오류가 나면 compiler 버전 문제이니 최신 compiler로 빌드를 해보길 바란다. 
(nullptr은 C++11 부터 이용 가능하기 때문에 이 버전 이상을 사용해야 한다. 다음의 사이트를 추천한다. http://cpp.sh/)

출력화면

두 객체가 겉보기엔 별개지만 같은 데이터를 공유하고 있다. 이런 복사를 얕은 복사(shallow copy)라고 한다.

main함수가 끝나면 자동적으로 소멸자를 통해 v1부터 arr에 저장돼있던 메모리를 반납하게 되는데 그렇게 되면 

v2가 가리키고 있던 arr이 사라지면서 오류가 발생한다. 그래서 시스템이 비정상적으로 종료한다.

이것이 바로 얕은 복사의 문제점!! --> 두 개의 객체가 완전히 분리되도록 복사하는 방식이 필요하다.

 

 ● 얕은 복사의 문제점 - 복사 생성자로 극복!

 ▷ VecF.h 수정본

  VecF 클래스의 참조인 fv를 매개변수로 받아서 그대로 복사하게끔 복사 생성자를 만들어봤다.

....
class VecF {
	int n;
	float *arr;
public:
	VecF(int d, float* a=nullptr) : n{ d } {
	arr = new float[d];
	if (a) memcpy(arr, a, sizeof(float) *n);
	}
    
    /*복사 생성자*/
	VecF(const VecF& fv) : n{ fv.n } {
	arr = new float[n];
	memcpy(arr, fv.arr, sizeof(float) *n);
	}
    .....
};
  • VecF 클래스의 객체는 fv의 n값을 그대로 똑같이 갖게 하면 되기 때문에 n의 값을 fv의 n으로 초기화해줬다.
  • arr은 데이터를 저장할 메모리를 가리키도록 new 명령어로 새 메모리를 할당받고 그것을 arr이 가리키게 설정한다.
  • 이 상태에서 memcpy 명령어로 arr (목적지) 에 fv의 arr (소스) 내용이 들어가도록 4*n 바이트 만큼 복사를 해준다.
  • --> 완전히 별개의 새로운 객체 하나가 만들어지도록 생성자를 정의했다.

 

 ▷ 수정된 클래스로 VFMain1.cpp 실행하면?

이제는 메모리를 새로 할당 받아 데이터를 복사해서 "VecF v2(v1);"에서 이전과 달리 깊은 복사(deep copy)가 일어난다. 

  • v2는 완전히 새로운 메모리를 할당받고 새로운 객체를 만들어서 v1과는 완전히 별개의 객체가 된다.
  • 이 경우에는 main함수에서 return 0;을 만나 소멸자가 동작하여 v1의 메모리를 반환해도 v2와는 상관이 없는 일이 되어 오류가 발생하지 않고 v2의 메모리도 반환되며 프로그램이 문제 없이 종료된다.

오류없이 소멸자가 동작한다.

 

 ● 복사 생성자를 무조건 써야하나?

 ▷ 만약 동적 메모리 할당과 같은 동작이 필요하지 않고 그냥 데이터 멤버를 그대로 복사해도 무방한 프로그램이라면 복사 생성자를 별도로 만들 필요가 없다. 

  하지만 복사하는 과정에서 여러가지 추가 처리가 필요하면 복사 생성자를 별도로 만들어 사용하는 것이 좋다.

 

좀만 쉬어갑시다.....................

 


▣ 이동 생성자

 ●  이동 생성자(move constructor)?

  ▷ r-value 참조로 전달된 같은 클래스의 객체의 내용을 이동하여 객체를 만드는 생성자

  ▷ 복사 생성자가 효과적이지만 때에 따라 복사를 자꾸 하다보면 메모리에 계속 카피해서 효율이 떨어질 수 있다.

  ▷ 이동 생성자를 사용해서 효율을 높일 수 있다.

왜 이동 생성자가 필요한지 아례 사례를 통해 자세히 알아봅시다.

 

 ●  불필요한 복사의 비효율성

  •  fv.arr과 arr의 값을 합해서 tmp에 저장하는 함수인 add함수를 추가했다.
class VecF {
	VecF(int d, float* a=nullptr)
    
    //복사 생성자
	VecF(const VecF& fv) : n{ fv.n } {
	arr = new float[n];
	memcpy(arr, fv.arr, sizeof(float) *n);
	}
	
	~VecF() {
		delete[] arr;
	}
	VecF add(const VecF& fv) const {
	  VecF tmp(n);   //벡터의 덧셈 결과를 저장할 임시 객체 
	  for (int i = 0; i < n; i++)
	    tmp.arr[i] = arr[i] + fv.arr[i];
	  return tmp;
	}
    ....
};

  ▷ arr의 벡터 요소와 fv의 i번째 arr요소를 더하고 그 결과를 tmp의 i번째 arr값으로 저장한다.

int main()
{
   float a[3] = {1,2,3};
   float b[3] = {2,4,6};
   VecF v1(3,a);
   VecF v2(3,b);
   VecF v3(v1.add(v2));
   
}
  • v1은 데이터 3개로 구성되고 a의 초깃값을 가진다. 
    v2도 데이터 3개로 구성되고 b의 초깃값을 가진다.
  • v1과 v2에는 각각 1,2,3과 2,4,6이 더해 있는 상태가 된다.
  • 이 상태에서 'v1.add(v2)'를 하면 v1의 add() 멤버함수를 통해 v2의 값이 v1에 더해진다.
  • 더해진 결과가 tmp에 대입되고 값을 retrun하면서 결과값이 v1.add(v2)로 나온다.
  • 이때 반환 객체는 이 결과값을 가지로 v3을 만든다. 즉, v3의 생성자의 매개변수로 v1.add(v2) 객체를 넣은 것이다.
  • 위와 같은 경우에는 복사 생성자가 동작을 한다. 복사 생성자가 동작하고 나면 반환된 객체는 v3에 복사한 후 제거가 된다. 이제 필요가 없어졌으니. 
  • 데이터를 복사한다는 건 v3에 새로 메모리를 할당(new)하고 memcpy로 복사를 하는 연산을 필요로한다.
  • 데이터의 양이 많아지면 연산이 엄청나게 많아질거고 복사하는 데이터의 양이 급증할 것이다. 시간도 많이 소비할 것이다.
  • return되는 tmp라는 객체는 v3에 v1.add(v2) 객체를 반환하고 나면 제거되는 객체다. 
  • 하지만 이 tmp 객체를 제거하지 않고 계속 이용하면 새로 메모리를 할당하지 않고 더 효율적으로 동작할 수 있다. 

  ▷ 이런 경우 포인터 값을 이용하여 메모리를 가리키는 대상만 바꿔주고 복사를 실질적으로 하지 않으면 더 효율적으로 동작할 수 있다. 

  ▷ 이게 바로 이동 생성자! 이동 생성자에서는 r-value 참조를 이용하여 더 효율적으로 동작하게 만들 수 있다.

 

 ●  r-value 참조

  ▷ a = b + 10; 라는 수식에서 a가 l-value, b+10이 r-value이다. (left value / right value)

  ▷ C++11에서부터 수식의 분류를 다음과 같이 세분화했다.

  • 값을 저장할 수 있는 실체가 있는 것을 l-value라고 부른다. 변수, 객체, 포인터 등이 해당된다.
  • 실체가 없이 값을 제공하기 위한 목적으로만 사용되는 것을 r-value라고 부른다.
  • xvalue는 expiring value로 곧 소멸될 값을 의미한다.
  • prvalue는 pure r-value는 상수값, 함수가 return하는 값 등이 해당된다. 
  • lvalue는 l-value 참조로 참조할 수 있다.
  • rvalue (xvalue & prvalue)는 r-value 참조로 참조할 수 있다.

 

 ▷ r-value 참조 선언

  • r-value 참조는 '&&' 기호로 선언한다. (l-value 참조는 '&'기호로 선언한다.)

 

 ▷ l-value 참조와 r-value 참조의 사용 예

1) VecF v1(3), v2(3);
2) VecF& vLRef = v1;  // l-value 참조로 l-value를 참조한다.
3) int& a = 10;       // 오류: l-value 참조로 r-value를 참조할 수 없다.
4) const int& b = 20; //상수 l0value 참조로는 r0value를 참조할 수 있다.
5) int&& c = 30;      // r-value는 r-value 참조로 참조할 수 있다.
6) VecF&& vRRef1 = v1.add(v2);  //함수의 반환 객체는 r-value다.
7) VecF&& vRRef2 = v2;    //오류: r-value 참조로 l-value를 참조할 수 없다.
  • 2)에서 v1은 1)을 통해 실체가 생겼기 때문에 l-value라서 l-value로 참조 가능하다.
  • 3)에서 10은 prvalue이기 때문에 r-value라서 l-value로 참조할 수 없다.
  • 4)에서 b는 l-value 참조지만 const로 상수 참조이기 때문에 이 경우에는 20이 prvalue여도 l-value로 참조 가능하다.
  • 4)까지는 모두 l-value 참조였다.
  • 5)에서 c를 r-value 참조하는데 30이 리터럴이기 때문에 r-value이므로 r-value 참조가 가능하다.
  • 6)에서 v1에서 add함수를 이용해 덧셈을 하면 return값이 반환되니 r-value이므로 r-value가 가능하다.
  • 7)에서 v2는 실체가 있는 객체이기 때문에 l-value이므로 r-value로 참조할 수 없다.

 

 ●  이동 생성자의 선언 형식

class ClassName {
   ....
public:
   /* 이동 생성자 */
   ClassName(ClassName&& obj){
     .... //생성되는 객체에 obj의 내용을 이동하는 처리
   }
   ....
};
  • 매개변수로 전달받은 객체의 내용을 r-value 참조로 받고 객체의 내용을 이동하는 처리를 한다. 
  • 내용을 이동하기 때문에 내용이 바뀐다는 의미이므로 const 를 사용할 수 없다. 

 

 ●  이동 생성자의 활용 - VecF.h 수정본-2

....
class VecF {
	int n;
	float *arr;
public:
    ....
    /* 복사 생성자 */
    VecF(const VecF& fv) : n{ fv.n } {
     arr = new float[n];
     memcpy(arr, fv.arr, sizeof(float) *n);
    }
    
    /* 이동 생성자 */
    VecF(VecF&& fv) : n{fv.n}, arr{fv.arr} {
     fv.arr = nullptr;
     fv.n = 0;
    }
    
    /* 소멸자 */
    ~VecF() {
     delete[] arr;
    }
    .....
};
  •  복사 생성자와는 달리 이동 생성자에서는 arr에 새로 메모리를 할당해주지 않는다.
  •  arr{fv.arr} fv의 arr이 가리키고 있는 걸 arr이 똑같이 가리키게 한다.
  •  fv.arr에 nullptr를 넣어줘서 fv.arr은 아무 메모리도 가지고 있지 않게 된다. arr에 데이터를 넘겨준 셈이 된다.
  •  객체가 없어질 때 아무 문제가 없도록 데터의 개수를 fv.n = 0으로 설정해준다.
  •  delete 연산자에서 arr이 nullptr이기 때문에 아무것도 소멸이 되지 않는다.

 ▷ 즉, 값을 제공하는 객체의 내용을 다른 객체이 이동시키고 원래 객체가 없어질 때 아무 문제가 없도록 nullptr, 0으로 설정해준다.

 ▷ main함수를 통해 다시 실행해보면 어떤 변화가 있을까?

int main()
{
   float a[3] = {1,2,3};
   float b[3] = {2,4,6};
   VecF v1(3,a);
   VecF v2(3,b);
   VecF v3(v1.add(v2));
   ....
}
  • v1 = {1,2,3}; v2 = {2,4,6}; 이므로 v1.add(v2)를 통해 temp = {3,6,9};가 되고 temp를 return한다.
  • 함수의 return 값이니까 prvalue (rvalue)가 되어 이동 생성자(r-value 참조)가 동작하게 된다.
  • 이 경우에는 복사 생성자에 const 처리를 했기 때문에 복사 생성자도 정상 동작할 수도 있다. 하지만 이동생성자가 우선이므로 이동생성자가 동작하게 된다.
  • VecF(VecF&& fv)에서 temp의 값 {3,6,9}를 arr이 가리키게 하고  return된 값의 arr 항목은 nullptr로 만들어준다.
  • 즉 v1.add(v2)는 v3로 값을 이동하고 v1.add(v2)는 사라진다고 보면 된다.

 ▷ 값을 이동하기만 해서 복사한 것보다 효율적으로 동작한다.

728x90
반응형