본문 바로가기

컴퓨터공학/C, C++

C,C++> 생성자

생성자 

생성자

객체 초기화

클래스 객체를 선언하면 메모리에 객체가 생성되는데 메모리에 할당될 뿐이지 초기화는 되지 않아서 객체 내 멤버 변수들은 모두 쓰레기값을 갖고 있다. 쓰레기값을 가지고 잇는 객체는 쓸모가 없다. 그래서 객체 선언문 다음에는 원하는 상태로 초기화하는 대입문이 따라온다.

원하는 값을 직접 지정하는 것은 원론적인 방법이긴 하지만 여러 줄의 코드가 필요해서 효율적이지 않다. 

멤버가 많아지면 그만큼 초기화 문장도 늘어난다. 그래서 선언문이 초기화를 겸할 수 있으면 간결해질 것이다. 

그러나 멤버 수와 타입이 가변적이므로 정수형이나 실수형처럼  단순 대입형태 초기화는 불가능하다. 

초기화를 전담하는 별도 함수가 필요하다. 

객체 초기화하는 특별한 함수를 생성자(Constructor)라고 부른다. 

클래스 스스로 자신을 초기화하는 방법을 정의해서 클래스를 기본 타입과 동등하게 만드는 언어적 장치이다. 

 

생성자 호출

생성자 호출은 두 가지 방법이 있다. 

암시적인 방법 : Position Here(30,10,'A');

명시적인 방법 : Position Here = Position(30,10,'A');

내부 동작은 달라도 객체 초기화 효과는 동일하다. 

암시적인 방법은 메모리 할당 후 생성자를 호출하여 할당된 메모리를 초기화한다. 

명시적인 방법은 두 가지 형태가 있다. 

암시적 방법처럼 초기화하는 컴파일러도 있고

이름없는 임시 객체를 먼저 생성한 후 대입하는 컴파일러도 있다. 

 

 

생성자 인수

생성자가 객체 초기화를 위해서 멤버 모든 초기값을 인수로 전달받아야 한다. 

그래서 생성자 형식 인수 목록은 보통 멤버 목록과 일치하는 경우가 많다. 

이 때 형식 인수 이름이 멤버 이름과 같아서는 곤란하다. 

 

왜냐하면 함수 본체에서 참조하는 변수는 객체 멤버 변수가 아니라 형식 인수이며 자신에게 자신의 값을 대입하는 아무 의미없는 코드가 되기 때문이다. 생성자 본체에서 전달받은 인수와 초기화할 멤버 이름을 구분해야 한다. 

다음과 같은 여러 가지 방법을 쓸 수 있다. 

1. 형식 인수에 일정 접두를 붙여서 멤버 이름과 구분한다. 

2. 멤버 이름을 작성하는 규칙을 정하고 규칙대로 멤버 이름을 짓는다. 

3. 형식 인수 이름과 멤버 이름을 같이 쓰되 함수 본체에서 멤버 변수를 참조할 때 범위 연산자를 사용한다.

Position(int x, int y, char ch){
	Position::x = x;
    Position::y = y;
    Position::ch = ch;
}

이외 this 라는 포인터를 사용하는 방법도 있다.

 

생성자 오버로딩 

오버로딩 함수처럼 생성자도 호출부 인수 목록으로 호출할 함수가 선택된다.

 

파괴자

객체가 사라질 때 반대 처리를 할 함수도 필요하다. 

컴퓨터 안에서 움직이는 모든 것들은 항상 자신이 생성되기 전 상태로 환경을 돌려놓을 의무가 있다. 

뒷처리하는 특별 멤버 함수를 파괴자(Destructor)라고 한다.  객체가 소멸될 때 컴파일러에 의해 자동으로 호출된다. 

파괴자 이름은 클래스 이름 앞에 ~를(tilde라고 읽는다) 붙인다. 

인수와 리턴값은 가지지 않는다. 

 

생성자 파괴자 특징

1. 이름이 정해져 있다.

2. 리턴값이 없다. 

3. 반드시 public 액세스 속성을 가져야 한다.

4. 생성자는 인수가 있지만 파괴자는 인수가 없다. 

5. friend도 static도 될 수 없다. 클래스 내부 함수로 friend 지정 없이 멤버를 마음대로 액세스할 수 있다. 초기화와 정리의 대상이 클래스가 아니라 개별 객체라서 static일 필요가 없다.

6. 파괴자는 가상 함수로 정의할 수 있지만 생성자는 가상 함수로 정의될 수 없다. 

7. 둘 다 디폴트가 있다. 디폴트 생성자는 인수를 취하지 않고 아무런 동작도 안한다. 디폴트 파괴자도 역시 아무 동작하지 않는다. 

 

객체 동적 생성 

정적으로 생성하든 동적으로 생성하든 생성자와 파괴자가 모두 호출된다. 

malloc으로 객체 생성 시 객체를 위한 메모리만 할당될 뿐 객체가 사용하는 메모리는 할당되지 않는다. 

malloc은 메모리 할당 함수이므로 지정한 바이트 수 만큼 메모리만 할당하고 생성자를 호출하지 않는다. 

free 마찬가지로 파괴자를 호출하지 않는다. 

 

여러가지 생성자

디폴트 생성자

디폴트 생성자는 기본 생성자라고 한다. 인수를 가지지 않는 생성자이다.

생성자는 오버로딩이 가능해서 여러 개 둘 수 있는데 그 중 인수가 없는 생성자를 디폴트 생성자라고 부른다. 

디폴트 생성자는 어떤 값으로 초기화하고 싶은지를 전달하는 수단인 인수가 없다.

그래서 객체 멤버에 의미있는 값을 대입하지 못한다. 주로 모든 멤버를 0, 1 또는 NULL이나 빈 문자열로 초기화한다. 

여기서 0은 실용적인 의미를 가지는 값이라기보다 단순히 아직 초기화되지 않았음을 표시하는 역할이다. 

쓰레기값보다 0이라도 쓰면 멤버 함수에서 값을 사용하기 전에 초기화되어 있는지를 점검할 수 있기 때문이다. 

즉, 디폴트 생성자는 쓰레기를 치우는 것이다. 

다음과 같은 방법으로 표현할 수 있다.

1. Position Here;

2. Position Here = Position();

3. Position *pPos = new Position;

4. Position *pPos = new Position();

 

컴파일러가 디폴트 생성자를 만드는 경우는 클래스가 생성자를 전혀 정의하지 않을 때뿐이다. 

만약 정수 하나를 인수로 취하는 생성자가 정의되어 있으면 이 클래스는 디폴트 생성자를 가지지 않는다.

그래서 Position Here; 선언문을 쓰게 되면 해당하는 생성문이 없어서 에러로 처리한다. 

만약 에러없이 작동하게 만들고 싶다면 Position(int) 생성자를 없애든가 

아니면 Position() 생성자를 오버로딩해야 한다. 

 

생성자가 인수를 가지고 있어도 디폴트 인수 기능으로 디폴트 생성자가 되는 경우가 있다. 

다음처럼 생성자를 만들면 인수없이 호출해도 된다.

Position(int ax=0, int ay=0, char ach='')

 

디폴트 생성자가 없는 클래스는 객체 배열을 선언할 수 없다. 

왜냐하면 배열을 선언하면서 초기값을 못 주기 때문이다. 

 

 

복사 생성자

void main(){
	Person Boy("이첨사", 22);
    Person Young = Boy;
    Young.OutPerson();
}

Young객체가 Boy객체로 초기화될 때 멤버별 복사가 된다.

Young의 Name 멤버는 Boy의 Name과 동일한 번지를 가리킨다. 

정수형인 Age끼리 값이 복사되는 것은 문제없다. 그러나 포인터끼리 복사는 문제가 된다.

 

 

int a=3;
int b=a;
b=5;

b가 생성될 때 a값으로 초기화되어 a와 b는 같은 값이다.

두 변수는 독립적으로 동작한다. 

b에 5를 대입해도 a는 영향 받지 않는다. 

 

그러나 Name은 포인터 복사라서 같은 값을 가리키고 얕은 복사이다. 

두 객체가 같은 메모리를 공유하고 한 쪽이 Name을 변경할 때 다른 쪽도 영향을 끼친다. 

 

따라서 깊은 복사를 해야 한다. 복사 생성자가 필요하다. 

 

class Person
{
private:
	char *Name;
    int Age;
    
public:
	Person(const char *aName, int aAge){
    	Name = new char[strlen(aName)+1];
        strcpy(Name, aName);
        Age = aAge;
    }
    Person(const Person &Other){
    	Name = new char[strlen(Other.Name)+1];
        strcpy(Name,Other.Name);
        Age=Other.Age;
    }
    ~Person() {
    	delete [] Name;
    }
}

Person 복사 생성자는 동일 타입 Other를 인수로 전달받아 자신의 Name에 Other.Name길이만큼 버퍼를 새로 할당하여 복사한다. 

새로 메모리를 할당받았으니 온진히 자기 것이다. 

Age는 단순 변수라서 값만 대입하면 된다. 

 

컴파일러는 Person Young = Boy; 구문을 

Person Young = Person(Boy);으로 해석한다. 

 

객체를 인수로 전달될 때

함수의 인수로 객체를 넘길 때도 복사 생성자가 호출된다. 

복사 생성자가 없다면 얕은 복사를 하며 두 객체는 공유하는 상황이 된다. 

인수는 지역 변수이므로 함수가 리턴할 때 인수는 파괴자가 호출되고 메모리가 해제된다. 

이후 이미 해제된 메모리를 참조하고 있으므로 에러가 발생한다. 

복사 생성자가 정의 되어 있으면 깊은 복사를 하므로 문제 없다. 

인수 전달 뿐만 아니라 리턴값으로 돌려질 때도 복사 생성자가 호출된다. 

결론은 함수 인수로 사용되거나 리턴값으로 사용되는 객체는 반드시 복사 생성자를 정의해야 한다. 

 

복사 생성자 인수

복사 생성자 인수는 반드시 객체 레퍼런스이다. 객체를 인수로 취할 수 없다. 

Person 형의 객체를 인수로 받으면 복사 생성자 인수는 

Person(cosnt Person Other)이다.

이럴 경우 Person Young = Boy;는 Person Young = Person(Boy); 이므로

인수에서 const Person Other = Boy가 된다. 

이건 또 const Person Other = Person(Boy)이고

이건 또 const Person Other = Boy이다. 

계속해서 무한 재귀 호출이 발생해서 에러로 처리한다. 

이러한 이유로 복사 생성자 인수로 객체를 전달할 수 없다. 

 

포인터로 받으면 어떨까?

포인터는 주소값을 받아서 한 번만 복사되며 무한 호출되지 않는다. 

그러나 Person Young = Boy; 선언문은 Person Young = Person(Boy)인데 이러면 포인터와 맞지 않다. 

포인터로 취하는 생성자는 복사 생성자로 인정되지 않는다. 

포인터로 객체를 복사하려면 Person Young = &Boy가 되어야 하는데,

이것은 일반적인 변수 선언문과 형식이 일치하지 않는다. 

int i = j; 라고 하지 int i = &j라고 선언하지 않는다. 

 

객체 이름은 그대로 쓰되, 함수 내부에서는 전달받은 것을 암시적으로 *연산자를 적용하는 레퍼런스라는 것이 필요해졌다.

C에서는 필요하지 않았던 레퍼런스 개념이 C++에서 필요해진 이유이다. 

객체 선언문, 연산문을 기본 타입과 일치하기 위해서 생겼다. 

 

디폴트 복사 생성자

클래스가 복사 생성자를 정의하지 않으면 컴파일러가 디폴트 복사 생성자를 만든다.

컴파일러가 만든 건 멤버끼리 1:1 복사를 해서 원본과 완전이 같은 사본을 만들 뿐이다.

깊은 복사는 하지 않는다. 

 

멤버 초기화 리스트

C++는 일반 타입들도 클래스와 동등하게 취급하며 클래스 문법이 일반 타입에 적용된다. 

int a = 3;
int a(3);

위 문장은 같은 의미이다. 

 

생성자 본체는 보통 전달받은 인수를 멤버 변수에 대입하는 대입문으로 구성된다. 

= 연산자를 쓰는 대신 초기화 리스트(Member Initialization List)라는 걸 사용할 수 있다. 

초기화 리스트는 함수 선두와 본체 사이에 : 을 찍고 멤버와 초기값 대응 관계를 나열한다. 

Position(int ax, int ay, char arch) : x(ax), y(ay), ch(ach)

{

}

 

상수 멤버 초기화

상수는 선언할 때 반드시 초기화해야 한다. 상수값이 없으면 에러 처리한다. 

const int year; 이런 식은 안된다는 뜻이다.

다만 클래스 멤버일 경우 객체가 만들어질 때까지 초기화를 연기할 수 있고,

생성자 초기화 리스트에서만 초기화가 가능하다. 

지금부터 왜 그러지 알아 보자.

class Some{
public:
	const int Value;
    Some(int i) : Value(i){ }
};

위 처럼 작성해야 한다.

상수는 값을 변경할 수 없으며 대입 연산 자체가 인정되지 않는다. 그래서 다음 코드는 안된다.

Some(int i) { Value = i; }

 

그래서 초기화 리스트라는 문법이 필요하다. 

초기화 리스트는 생성자에서만 이 문법이 적용한다. 

상수는 선언할 때 주어야 하나 클래스 정의문에 다음처럼 초기값을 줄 순 없다. 

class Some {
public:
	const int Value=5;

왜냐하면 클래스 선언문은 컴파일러에게 클래스가 어떤 모양을 하고 있다는 것을 알릴 뿐이다. 

실제 메모리를 할당하지 않는다. 

그러므로 Value 멤버는 아직 메모리에 없다. 

메모리에 없는 존재를 초기화할 순 없다. 

그래서 상수는 객체가 생성할 때 초기화해야 한다. 

따라서 상수 멤버 초기화 책임은 생성자에게 있다. 

결론은 상수 멤버를 가지는 클래스 모든 생성자들은 상수 멤버에 초기화 리스트를 가져야 한다. 

이것을 어기면 에러 처리가 된다.

 

레퍼런스 멤버 초기화

레퍼런스 변수 ri를 멤버로 갖고 있는데 생성자는 ri가 참조할 실제 변수를 인수로 받아서 ri가 이 변수의 별명이 되도록 한다. 그런데 레퍼런스 멤버는 다음처럼 대입연산로 초기화할 수 없다. 

Some(int &i) { ri = i; }

 

왜 안될까? 위 내용은 레퍼런스 대상체를 지정하는 게 아니라 레퍼런스가 참조하고 있는 변수에 값을 대입한다는 뜻이다.

그래서 레퍼런스 멤버는 반드시 초기화 리스트에서 대상체를 지정해야 한다. 

그리고 상수 멤버 초기화와 동일하다. 왜냐하면 레퍼런스는 상수 포인터이기 때문이다. 

 

정리하자면 레퍼런스는 선언할 때 짝이 될 변수를 반드시 써야 한다. 

근데 초기식 없이 선언할 수 있는 경우가 있는데 그 중 하나가 클래스 멤버로 선언할 때이다. 

짝이 없는 레퍼런스는 절대로 존재할 수 없다. 

 

포함된 객체 초기화

클래스 객체를 멤버로 가질 수 있다. 이 때도 초기화 리스트를 사용한다. 

예를 들어 다음처럼 할 수 있는데 

class Position {
public:
	int x, y;
    Position(int ax, int ay) { x=ax; y=ay; }
};

class Some {
public:
	Position Pos;
	Some(int x, int y) : Pos(x,y){ }
};

Some 클래스가 Position 클래스 객체 Pos를 포함하고 있다.

포함된 Pos객체를 초기화하기 위해 생성자를 다음처럼 할 수 없다.

Some(int x, int y) { Pos(x,y); }

왜냐하면 생성자는 객체를 생성할 때만 호출할 수 없다.

Some 클래스 안에서 Pos 객체가 이미 생성되어 있는데 Some 생성자 함수 안에서 명시적으로 호출할 수 없다. 

그래서 멤버로 포함된 객체를 초기화할 때 초기화 리스트를 사용해야 한다. 

 

Some(int x, int y) { Position Pos(x, y); }

생성자를 호출하는 문장처럼 보이지만 위 내용은 안된다.

블록 안에 있어서 임시 생성되는 지역 객체이기 때문에 멤버 객체와는 상관이 없다.

 

포함된 객체가 디폴트 생성자가 있다면 컴파일러가 디폴트 생성자를 생성해서 에러는 안 생긴다.

대신 쓰레기 값이 있기 때문에 원하는 값이 안 들어있을 것이다.

따라서 초기화 리스트를 이용하여 적절한 생성자를 호출해서 포함된 객체를 초기화해야 한다. 

 

타입 변환

변환 생성자 

클래스가 일반 타입과 같아지려면 타입 변환을 할 수 있는 장치가 필요하다. 

첫 번째 장치가 바로 변환 생성자(Conversion Constructor)이다. 

조건은 인수가 하나만 취한다. 둘 이상이면 변환 생성자가 아니다. 

class Time {
private:
	int hour,min,sec;
public:
	Time(){ }
    Time(int abssec) {
    	hour = abssec/3600;
        min = (abssec/60)%60;
        sec = abssec%60;
    }
};

위와 같은 정의를 만들었기 때문에 다음처럼 간접적으로 호출할 수 있다.

Time Now = 3723;

int 와 Time은 호환되지 않지만 변환 생성자가 있어서 컴파일러에 의해 자동 변환된다. 

작동원리는 다음과 같다. 먼저 컴파일러는 정수를 Time객체로 변환할 수 있는 변환생성자를 찾아 호출한다.

Time(int) 생성자를 호출하여 임시 객체를 만들고 이 객체를 Now에 대입한다. 

void func(Time When){ 
}
void main(){
	Time Now(23423);
    func(Now);
    func(2345);
}

func함수가 Time 객체도 받고 정수도 받아서 잘 작동한다.

하지만 문자열이나 실수같은 호출문도 에러가 생기지 않고 작동한다.

왜냐하면 문자형이나 실수도 정수형으로 암시적 변환이 가능하고 이렇게 변환된 정수형이 다시 변환 생성자로 Time 객체로 변환 가능하기 때문이다. 

이런 모호한 상황을 막기 위해 엄격한 타입 체크가 필요하다. 그래서 explicit 키워드를 변환 생성자 앞에 붙이면 된다. 

그러면 암시적 형 변환이 사용할 수 없게 금지한다. 

대신 캐스트 연산자를 사용하는 건 가능하다.

Time Now = 3424; //불가능
Time Now(324); //가능
Time Now = (Time)23423; //가능

 

변환 함수 

지금까지 int에서 Time 클래스로 변환하는 예시를 봤는데, 그 반대 경우도 살펴보자. 

객체에서 일반 타입으로 변환할 때 필요한 기능이 변환 함수(Conversion Function)이다.

변환 함수는 캐스트 연산자에 대한 오버로딩의 한 예이다. 

형식은 아래처럼 쓰면 된다. 

operator 변환타입()

본체

}

변환 함수는 변환 생성자처럼 explicit로 암시적 변환을 금지하는 장치가 없기 때문에 주의깊게 사용해야 한다. 

int Nox, Noy;
Time Now;
gotoxy(Nox, Now);

예를 들어 Noy를 넣어야 하는데 실수로 Now를 입력해도 문법적으로 틀린 건 아니라서 잡기도 힘들다. 

왜냐하면 Time 객체는 int 변환함수가 있기 때문이다. 

암시적 변환이 걱정된다면 변환 생성자나 변환 함수를 만들지 않고 TimeToInt, IntToTime 처럼 함수로 만들어서 필요할 때 사용하면 더 안전하다.

 

변환 함수로 필요한 변환을 모두 할 수 있는데 변환 생성자가 굳이 필요한 이유는 무엇일까?

왜냐하면 기본 타입은 컴파일러에 내장되어 있어서 마음대로 수정할 수 없다. 

그래서 변환함수를 정의할 수 없다. 예를 들어 클래스는 모두 실수형으로 변환 가능하지만 double이 클래스형으로 변환할 수 없다. 그래서 클래스가 double로 부터 자신을 생성해야 한다. 이 때 변환 생성자가 필요하다. 

 

'컴퓨터공학 > C, C++' 카테고리의 다른 글

C,C++> 연산자 오버로딩  (0) 2022.08.18
C,C++> 캡슐화  (0) 2022.08.09
C,C++> 클래스  (0) 2022.08.04
C,C++> 기타 내용  (0) 2022.08.03
C,C++> 파일 입출력  (0) 2022.07.28