상세 컨텐츠

본문 제목

C++ 생성자, 파괴자, 복사 생성자, 복사 대입 연산자

C++/C++98

by deulee 2023. 8. 3. 22:46

본문

생성자

지금까지 클래스는 타입이기 때문에 기본 자료형과 같은 모든 역할을 할 수 있다고 했다. 그러면 클래스의 객체는 어떻게 초기화할 수 있을까?

 

클래스의 객체는 선언하는 동시에 메모리에 해당 인스턴스가 생기게 된다. 하지만 메모리만 할당 될 뿐이지 초기화가 되어있지 않아 쓰레기값이 들어가 있을 것이다.

 

그래서 보통 우리는 이를 해결하기 위해 객체를 선언하고 해당 멤버에 값을 대입하는 방법을 써왔다.

 

Point here;
here.x = 10;
here.y = 20;

이는 확실하게 값을 할당해 줄 수 있지만 인스턴스를 만들면 만들수록 비효율적이게 된다.

 

객체를 초기화할 수 있다면 얼마나 편할까? 그러나 C++은 객체에 대해 단순 타입에 적용되는 선언 및 초기화 문법을 제공하지 않는다. 즉, Point here = 10, 2; 이런식은 안된다는 것이다.

 

멤버의 수와 타입이 가변적이므로 단순한 대입 형태의 초기화는 안된다.

 

이를 해결하기 위해 객체를 초기화하는 특별한 함수가 있는데 이것을 생성자(Constructor)라고 부른다.

 

생성자는 컴파일러에 의해 호출되며 이름이 항상 클래스의 이름과 같아 고정적으로 정해져 있다. 그리고 초기화에 사용할 매개변수는 있지만 반환값은 없다.

class Point {
	private:
		int x;
		int y;
	public:
		Point() // 기본 생성자, 아무런 생성자가 정의되지 않을 경우 디폴트 생성자를 만든다.
		{

		}
		Point(int _x) // 생성자
		{
			int x = _x;
			int y = 4;
		}
		Point(int _x, int _y)
		{
			int x = _x;
			int y = _y;
		}
};

int main(void)
{
	Point p1;
	Point p2(1);
	Point p3(4, 7);
}

위의 예시를 보면 다양한 생성자들이 있다. 기본적인 생성자의 종류는 크게 두 가지가 있는데 다음과 같다.

 

1. 기본 생성자

 

사실 생성자는 우리가 호출 부에서 인자로 아무것도 넣지 않을때 기본적으로 항상 호출되는 생성자이다. 즉, Point p1;으로 선언하게 되면 기본 생성자가 호출되게 되어있다.

 

이때, 이 안에 특정값을 할당해주지 않으면 쓰레기값이 들어가게 되는 것이다.

 

2. 기본 생성자 이외의 나머지 생성자

 

위의 예시를 보면 기본 생성자를 포함해서 다양한 생성자가 있는것을 알 수 있다.

 

이 이유는 C++에서의 기능 중 하나인 함수 오버로딩 기능을 이용해서 해당 인자에 알맞는 매개변수 즉, 함수 시그니처를 가진 생성자를 호출하는 것이 가능하기 때문이다.

 

이를 통해 원하는 상황에 따라 값을 집어넣고 말고를 정할 수 있는 것이다.

 

함수 오버로딩에 대한 사전 지식이 없으면 해당 글을 보고 오자.

 

https://deulee.tistory.com/50

 

C++ 함수 오버로딩

함수 오버로딩이란? 함수 오버로딩(function overloading)은 다른 매개 변수를 가진 같은 이름의 여러 함수들을 만들 수 있는 C++의 기능이다. 왜 이런걸 만들었을까? 이건 나중에 나올 연산자 오버로딩

deulee.tistory.com

 

생성자 호출 방법

이미 위에서 언급했지만, 생성자를 호출하는 데에는 명시적•암시적 방법이 있다.

Point here = Point(10, 20); // 암시적인 방법

Point here(10, 20); // 암시적인 방법

// 기본 생성자를 호출하는 방법

Point here;
Point here = Point();
Point* here = new Point;
Point* here = new Point();

// 많이 실수하는 것
Point here(); // 이건 함수다. 생성자 호출 안함 ㅎㅎ

 

생성자가 호출되는 시기

// 반환할 때
Point createPoint(Point A) // 매개변수로 인자가 넘어갈 때
{
	Point ret; // 객체를 생성할 때
    
	return ret; // 반환할 때
}

int main(void)
{
	Point B; // 객체를 생성할 때
    
	createPoint(B);
	return 0;
}

생성자가 호출되는 시기는 대표적으로 3가지가 있다.

 

1. 일반 변수로 선언된 매개변수로 인자가 넘어갈 때 (call by value)

 

이때 메인 함수에 있는 지역변수 B와 createPoint 함수에 선언된 매개변수 A는 서로 다른 지역을 가지고 있다. 이 말은 서로의 메모리 주소가 다르다는 것이고 즉, 다른 객체라는 것이다.

 

이 말은 인자가 넘어갈 때 새로운 객체가 생성되고 즉, 생성자가 호출된다고 생각하면 된다.

 

2. 반환될 때

 

반환될 때 특정한 키워드가 붙어져 있는게 아닌 이상 새로운 객체가 만들어지며 반환이 된다.

 

왜냐하면 해당 함수 지역에서 사용되는 변수의 scope와 반환된 값의 scope는 다르기 때문이다.

 

그렇기 때문에 새로운 객체가 생성되고, 즉, 생성자가 호출된다.

 

3. 일반적으로 객체를 생성할 때

 

위에서 많이 언급되었으므로 넘어가겠다.

 

위의 3가지 특성은 객체를 함수로 넘길 때 포인터참조형을 사용해야 하는 이유가 되기도 한다.

 

동적 할당시 생성자의 동작 과정

동적 할당을 하게 될 경우 생성자는 어떻게 될까?

 

결과를 먼저 말하면 new 연산자를 사용하게 되면 힙 공간에 객체를 위한 메모리를 할당한 후 생성자를 호출하게 된다.

 

이때 생성자가 요구하는 인수를 전달해야 하는데, 아무것도 전달하지 않으면 기본 생성자가 호출된다.

 

Point* ptr = new Point(); // 기본 생성자 호출
delete ptr;

Point* ptr = new Point(3, 4); // 해당 시그니처를 가진 생성자 호출
delete ptr;

 

멤버 초기화 리스트

지금까지 생성자를 보면 본체에서 하나하나 대입해주고 있는 것을 알 수 있다.

 

이렇게 대입 연산자를 쓰는 대신 멤버 초기화 리스트(Member Initialization List)라는 것을 사용하여 초기화 할 수 있다.

 

Point(int _x, int _y) : x(_x), y(_y) // 멤버(인수) == (멤버 = 인수)
{}

 

위의 방식으로 하면 멤버 변수에 초기화작업을 하는 것과 같은 역할을 해 멤버 초기화 리스트를 사용하는 것이 대부분의 경우에 더 좋다.

 

여담이지만, 멤버 변수에 문자열이 있다면 대입을 통해서 깊은 복사를 하는 것이 좋다.

str = _str; // 이러면 얕은 복사
strcpy(str, _str); // 이러면 깊은 복사

 

이를 이용하면 좋은 이유는 대입 연산자를 사용할 수 없는 다음과 같은 상황에 대응할 수 있는 것이다.

 

class Number
{
	private:
		int& refNum;
		const int constNum;
		Point P;
	public:
		// 대입 연산을 하지 못하는 참조, 상수, 객체, 그리고 상속에 유용하다.
		Number(int& _ref, int _con) : refNum(_ref) , constNum(_con), P(7, 8)
		{}
};

 

1. 참조

 

참조는 선언과 동시에 무조건 초기화를 해야 하는데 클래스 멤버 변수로 쓰이는건 예외 케이스 중 하나이다.

 

그렇다고 생성자 내부에서 대입 연산자를 통해 할당은 못한다. 이때 사용할 수 있는게 위의 방법이다.

 

멤버 초기화 리스트는 말 그대로 초기화 시키는 작업이기 때문이다.

 

2. 상수

 

상수 또한 초기화로만 값을 할당할 수 있는데 위와 같은 상황이라고 할 수 있다.

 

3. 객체

 

생성자는 객체를 생성할 때만 호출할 수 있으며 외부에서 명시적으로 호출할 수 없다.

 

즉, Number(int x, int y) { P(x, y); } 는 안된다.

 

또한, Number(int x, int y) { Point P(x, y); } 안된다. 왜냐하면 이는 지역 객체일 뿐이며 생성자가 종료됨과 동시에 파괴(좀 이따 나옴)되기 때문이다.

 

물론 디폴트 생성자를 정의하면 해결할 수는 있지만 우리가 원하는 값을 매번 넣을 수 없기 때문에 좋은 방법은 아니다.

 

그렇기 때문에 위의 예시 코드가 가장 이상적이라고 할 수 있다.

 

4. 상속

 

나중에 다룰 내용이지만 상속받은 멤버를 초기화 할때도 초기화 리스트를 사용한다.

 

그 의외의 생성자

1. 복사 생성자

복사 생성자는 이미 생성되어 있는 같은 타입의 다른 변수로 초기화할 경우 호출되는 생성자이다.

Point a(10, 20);
Point b(a); // 복사 생성자 호출
Point c = b; // 복사 생성자 호출

 

그럼 복사 생성자의 내부는 어떻게 생겼는지 보도록 하자!

class Person
{
	private:
		char* name;
		int age;
	public:
		Person(const Person& other) // 참조자인것을 유의하자
		{
        	// 메모리를 할당하고 복사한 것을 보도록 하자. 깊은 복사라고도 한다.
			name = new char[strlen(other.getName()) + 1];
			strcpy(name, other.getName());
			age = other.age;
		}
		char* getName()
		{
			return name;
		}
};

 

자 이제 여기에서 주의깊게 봐야할 부분을 봐보도록 한다.

 

1. 참조자로 매개변수를 선언한 것

 

만약 참조자가 아니라 일반 변수로 매개변수로 설정했다면 어떤 일이 일어날까?

 

위에서 설명했던대로 매개변수가 call by value로 호출되었을 경우 복사 생성자가 호출될 때마다 새로운 객체복사하게 된다. 이때, 복사가 된다는 것이 중요하다. 그냥 생성자가 호출되는게 아니라 계속해서 복사 생성자가 호출되면서 stack overflow를 유발할 수 있다.

 

그리고 참조형으로 전달하게 될 경우 원본 객체를 직접적으로 조작할 수 있으므로 const 키워드를 붙여주어 실수로라도 원본 객체를 수정하는 일이 없어야 한다.

 

2. 깊은 복사

 

이는 복사 생성자 뿐만 아니라 대부분의 생성자에서 이루어져야 할 내용이다.

 

문자열을 그냥 복사(얕은 복사)를 하게 될 경우 두 개의 객체가 같은 문자열의 주소를 가지게 될 것이다.

 

이는 만약 한 개의 객체가 해당 문자열의 메모리를 해제하게 되었을 경우 다른 객체가 또 해제하게 될 경우 double free 에러가 나올 수가 있다.

 

이 외에도 동일한 문자열의 주소를 가지고 있는 복수의 개체는 얼마든지 문제를 일으킬 수 있다.

 

기본 복사 생성자

만약 클래스가 복사 생성자를 정의하지 않으면 컴파일러가 기본 생성자와 마찬가지로 기본 복사 생성자를 만든다. 컴파일러가 만드는 기본 복사 생성자는 멤버끼리 1 : 1로 복사함으로써 원본과 같은 사본을 만들 뿐 깊은 복사를 하지 않는다.

 

그러니 주의해서 사용하도록 하자.

 

대입 연산자

우리가 값을 할당하는 방법에는 다음과 같은 방법도 있다.

#include <iostream>

class Point {
	private:
		int x, y;
	public:
		Point()
		{}
		Point(int _x, int _y) : x(_x), y(_y)
		{}
		Point& operator=(const Point& other)
		{
			this->x = other.x;
			this->y = other.y;
			return *this;
		}
		void output(void)
		{
			std::cout << x << ' ' << y << std::endl;
		}
};

int main(void)
{
	Point t1;
	Point t2(2, 3);

	t1 = t2; // 복사 대입 연산자 호출
	t1.operator=(t2); // 위와 똑같다.
	t1.output();
	return 0;
}

t1 = t2 부분을 보도록 하자.

 

이렇게 할당하게 되는 경우 복사 대입 연산자를 호출하게 된다.

 

복사 대입 연산자 또한 복사 생성자나 다른 생성자와 마찬가지로 컴파일러가 기본적으로 만들어준다.

 

즉, 기본 복사 대입 연산자도 있다는 건데 내부는 기본 복사 생성자와 마찬가지로 얕은 복사를 하게 된다. 이 점은 똑같이 주의해줘야한다.

 

그럼 무엇이 다를까?

 

이는 연산자가 함수 오버로딩의 개념을 이용하여 만들어진 것이므로 사실상 t1.operator=(t2); 랑 똑같은 것이다.

 

이를 연산자 오버로딩이라고 하며 나중에 다루도록 하겠다. 우선 이런것도 있구나라고 생각하면 되겠다.

 

파괴자

하나의 객체가 기본적으로 가지는 것 중 마지막을 파괴자이다.

 

파괴자의 모습은 다음과 같다.

class Point{
	private:
		int x, y;
	public:
		~Point() // 파괴자
		{}
};

파괴자는 매개 변수가 없으며 객체가 소멸될 때 컴파일러에 의해 자동으로 호출된다. 파괴자의 이름은 클래스 이름앞에 ~를(tidle) 부ㅌ인 것으로 고정되어 있으며 인수와 리턴값을 가지고 있지 않다.

 

즉, 객체의 scope를 벗어나게 되면 자동으로 호출된다는 것이다.

 

최종적인 객체의 모습

#include <iostream>

class Point{
	private:
		int x, y;
	public:
		Point() // 생성자
		{}
		Point(const Point& other) // 복사 생성자
		{}
		Point& operator=(const Point& right) // 복사 대입 연산자
		{}
		~Point() // 파괴자
		{}
};

 

출처

http://www.soen.kr/lecture/ccpp/cpplec.htm

 

C/C++ 강좌

 

www.soen.kr

 

'C++ > C++98' 카테고리의 다른 글

C++ this  (0) 2023.08.05
C++ 프렌드  (0) 2023.08.05
C++ 함수 오버로딩  (0) 2023.08.03
C++ 참조자(reference)  (0) 2023.08.03
C++ 클래스  (0) 2023.08.03

관련글 더보기