생성자(몸체 초기화 vs 멤버 이니셜라이저)와 소멸자

Jin Hur·2021년 12월 8일
0

OOP with C++ 

목록 보기
8/30

reference:
"명품 C++ Programming" / 황기태
"전문가를 위한 C++" / 마크 그레고리

생성자

  • 생성자의 목적은 객체가 생성될 때 필요한 초기 작업을 하기 위함이다.
    예를 들어 멤버 변수의 값을 특정 값으로 설정(변수 초기화)하거나, 메모리를 동적으로 할당 받거나, 파일을 열거나, 네트워크를 연결하는 등 객체를 사용하기 전에 필요한 조치를 할 수 있도록 하기 위함이다.

  • 생성자 함수는 오직 한 번만 실행된다.
    생성자 함수는 각 객체마다 생성되는 시점에 오직 한 번만 자동으로 실행된다.

  • 생성자 함수의 이름은 클래스 이름과 동일하게 작성되어야 한다. 또한 리턴 타입을 선언하지 않는다.

  • 생성자는 중복(overloading)이 가능하다.
    생성자는 한 클래스에 여러 개를 만들 수 있다. 물론 매개변수나 타입이 서로 다르게 선언되어야 한다.
    ex) Circle(); // 매개변수가 없는 생성자
    ex) Circle(int r); // 매개변수가 있는 생성자

  • 생성자를 오버로딩 할 수 있는 이유는 사용자가 다양한 방법으로 객체를 생성하도록 함에 있다. 또한 생성자는 다른 오버로딩된 생성자를 호출할 수 있다.


생성자로 데이터 멤버를 초기화하는 방법

생성자로 데이터 멤버를 초기화하는 방법은 두 가지가 있다.

1. 생성자 몸체에서 초기화

AirlineTicket() {
	// 데이터 멤버 초기화
    passengerName = "Unkown";
    numberOfMiles = 0;
    hasEliteSuperRewardsStatus = false;
    ..
}

2. 생성자 이니셜라이져(ctor 이니셜라이져, 또는 멤버 이니셜라이져(member initializer)) <- 권장 방법

생성자 이름 뒤에 콜론(:)을 붙여서 표현한다.

AirlineTicket() 
	: passengerName("Unknown"), numberOfMiles(0), 
    hasEliteSuperRewardsStatus(false)
{}

멤버 이니셜라이져를 사용하면 초기화의 대상이 명확히 인식이 가능해지고, "선언과 동시에 초기화"가 이뤄지는 바이너리 코드가 생성되기에 성능에도 이점이 있다.
예를 들어 멤버 변수 num을 n으로 초기화하는 코드가 있다 가정하면, 몸체에서 초기화하는 경우(1) int num; num = n;으로 변수 선언 -> 초기화가 이루어지지만, 이니셜라이져를 통해선 int num = n;과 같이 선언과 동시에 초기화가 이루어진다.

class B {
private:
	int x, y;

public:
	B() {
		cout << "기본 생성자 호출" << endl;
	}
	B(int x, int y) {
		this->x = x;
		this->y = y;

		cout << "매개변수 존재 생성자 호출" << endl;
	}
};


class A {
private:
	B b;

public:

	A(int x, int y) {
		B b(x, y);
		this->b = b;
	}
};

int main() {

	A a(2, 3);
    
    // A 클래스 객체의 생성자 호출
    // 1) B b 선언 -> B 클래스 객체 기본 생성자 호출
    // 2) B b(2, 3); 호출 후 초기화 -> B 클래스 객체 매개변수 존재 생성자 호출
}

이러한 동작으로 인해 반드시 멤버 이니셜라이져를 써야하는 경우도 있다.

멤버 이니셜라이저를 통해 멤버를 초기화해야만 하는 상황

1) 상수 멤버가 있을 때 (const 데이터 멤버)
const 변수, 즉 상수 변수는 선언과 동시해 초기화를 해야만 오류가 나지 않는다. 따라서 이러한 상수가 멤버 변수로 있고, 초기화를 원한다면 멤버 이니셜라이저를 사용해야 한다.

class A {
private:
    const int num;
    
public:
     A() : num(0) {}
     
}

2) 레퍼런스 멤버가 있을 때 (레퍼런스 데이터 멤버)
const 변수와 마찬가지로 선언과 동시에 초기화를 해야한다.

class A {
private:
	B& b;

public:

	A(B& b) {
		this->b = b;
	}
};

따라서 멤버 이니셜라이저를 사용한다.

class A {
private:
	B& b;

public:

	A(B& b) : b(b)
	{}
};

3) 멤버의 생성자를 호출해야할 때 (디폴트 생성자가 정의되지 않은 객체 데이터 멤버)
앞서 보았듯 멤버 이니셜라이져를 사용하지 않으면, 멤버 변수가 객체 타입을 가질 때 선언 -> 초기화라는 두 번의 단계가 나뉘어 지고, 이 때 선언 단계 시 타입의 기본 생성자가 호출된다. 만약 기본 생성자가 선언되어 있지 않고, 오버로딩된(매개변수 존재) 생성자만 존재한다면 에러가 발생할 것이다.

class B {
private:
	int x, y;

public:
	//B() {
	//	cout << "기본 생성자 호출" << endl;
	//}
	B(int x, int y) {
		this->x = x;
		this->y = y;

		cout << "매개변수 존재 생성자 호출" << endl;
	}
};


class A {
private:
	B b;

public:

	A(B b) {
		this->b = b;
	}

	//A(B b) : b(b)
	//{}
};

int main() {

	B b(2, 3);
	A a(b);
}

따라서 멤버 이니셜라이저를 사용한다.

class A {
private:
	B b;

public:

	//A(B b) {
	//	this->b = b;
	//}

	A(B b) : b(b)
	{}
};

(4) 부모 클래스의 기본 생성자 외의 생성자를 호출하려 할 때 (디폴트 생성자가 없는 베이스 클래스)
상속 관계에서 자식 객체를 생성하면 부모 클래스의 기본 생성자도 호출된다. 최상위의 부모 클래스부터 차례대로 호출이 된다.

class Parent {
private:
	int num;

public:
	Parent() {
		cout << "부모 클래스의 기본 생성자 호출" << endl;
	}

	Parent(int num) {
		this->num = num;
		cout << "부모 클래스의 매개변수가 있는 생성자 호출" << endl;
	}
};

class Child : public Parent{
private:
	string name;

public:
	Child() {
		cout << "자식 클래스의 기본 생성자 호출" << endl;
	}

	Child(string s) {
		name = s;
		cout << "자식 클래스의 매개변수가 있는 생성자 호출" << endl;
	}
};

int main() {
	
	Child c1;

	Child c2("jin");

}

위 출력문과 같이 부모 클래스의 기본 생성자가 호출됨을 알 수 있다.
만약 부모 클래스의 기본 생성자가 없다면, 아래와 같은 에러가 발생한다.

(4-1) 매개변수가 존재하는 부모 클래스 활용

매개변수가 존재하는 생성자처럼 부모 생성자 중 오버로딩된 생성자를 호출해야할 필요가 있다면 이 때는 멤버 이니셜라이져 방식으로 부모 멤버를 초기화해야한다.

class Child : public Parent {
private:
	string name;

public:
	Child() 
		: Parent(Parent(3))
	{
		cout << "자식 클래스의 기본 생성자 호출" << endl;
	}

	Child(string s) 
		: Parent(Parent(3))
	{
		name = s;
		cout << "자식 클래스의 매개변수가 있는 생성자 호출" << endl;
	}
};


참고로 객체 타입의 데이터 멤버의 생성 순서는 클래스에 선언된 순서대로 일어나고 초기화된다.


소멸자

  • 소멸자의 목적은 객체가 사라질 때 필요한 마무리 작업을 하기 위함.
    동적으로 할당받은 메모리를 OS에 반납하거나, 열어 놓은 파일을 저장하고 닫거나, 연결된 네트워크를 해제하는 등의 작업을 하기 위함이다.

  • 소멸자는 오직 한개만 존재하며 어떤 값도 리턴해서는 안된다.

  • 소멸자가 선언되어 있지 않으면 디폴트 소멸자가 자동으로 생성된다.
    디폴트 소멸자는 아무 일도 하지 않고 단순 리턴하도록 만들어 진다.

참고로 지역 객체는 함수가 실행될 때 생성되고 함수가 종료할 때 소멸되지만, 전역 객체는 프로그램이 로딩될 때 생성되고 main() 종료한 뒤 프로그램 메모리가 사라질 때 소멸된다. 전역 객체나 지역 객체 모두 생성된 순서의 반대순으로 소멸된다.

반면, 힙 객체는 자동으로 삭제되지 않는다. 스마트 포인터를 사용하지 않는 한 말이다. 객체 포인터에 대해 delete를 명시적으로 호출하고 메모리를 해제해야 한다.

// 명시적 소멸자 호출 예제
int main() {
    Person* pPtr1 = new Person("jin", 27);
    Person* pPtr2 = new Person("chul", 26);
    
    cout << "jin의 나이: " << pPtr1->getAge() << endl;
    delete pPtr1;	// pPtr1이 가리키는 메모리 공간(힙)을 해제한다.
   	pPtr1 = nullptr;	// 잘못된 잘못을 막고자 널을 대입
    
    return 0;
}
// pPtr2에 대해선 delete를 통해 가리키는 메모리 공간을 해제하지 않았다. 

위와 같이 포인터가 가리키는 공간을 해제하지 않고 남겨두면 안 된다. 항상 deletedelete[]를 호출해서 동적으로 할당된 메모리를 해제해야 한다. 이러한 실수를 막고자 스마트 포인터를 사용하는 방법이 있다.

객체 타입의 데이터 멤버가 클래스에 선언된 순서대로 초기화되는 것과 반대로 객체의 생성 순서와 반대로 삭제된다는 규칙에 의해 데이터 멤버 객체도 클래스에 선언된 순서와 반대로 삭제된다.

0개의 댓글