26-1.생성자
26-1-가. 생성자
객체 초기화
클래스의 객체를 선언하면 메모리에 이 객체가 즉시 생성된다. 그러나 메모리만 할당 될 뿐이지 초기화는 되지 않으므로 객체내의 멤버 변수들은 모두 쓰레기값을 가지고 있을 것이다. 쓰레기값을 가지고 있는 객체는 쓸모가 없으며 그래서 객체 선언문 다음에는 통상 객체를 원하는 상태로 초기화하는 대입문이 따라 온다. 초기화를 해야만 비로소 유용한 정보를 가지는 객체가 된다.
Position Here; // 객체 선언
Here.x=30; // 멤버에 값 대입
Here.y=10;
Here.ch='A';
Position Here; 선언문에 의해 메모리에 Here객체가 할당되지만 이 객체의 x, y, ch 멤버는 초기화되지 않은 쓰레기값을 가지고 있다. Here가 위치를 가지는 문자라는 실세계의 대상을 표현하려면 (x, y)가 화면내의 유효한 좌표값을 가져야 하며 ch가 인쇄 가능한 문자 코드를 가져야 한다. 그래서 Position Here; 선언문 다음에 x, y, ch 멤버에 적당한 초기값을 대입하였다. 이 대입문은 Here가 유효한 객체가 되기 위해 반드시 필요하며 생략할 수 없다.
대입 연산으로 원하는 값을 직접 지정함으로써 쓰레기를 제거하는 것은 아주 원론적인 방법이기는 하지만 여러 줄의 코드가 필요해 효율적이지는 않다. 멤버가 많아지면 초기화 문장도 그만큼 늘어나야 한다. 객체 선언 후 반드시 초기화를 해야 한다면 선언문이 초기화를 겸할 수 있도록 하면 훨씬 더 간결해질 것이다. 정수형 변수를 선언할 때 int i=5; 문장으로 초기값을 줄 수 있는 것처럼 객체의 경우도 선언 시점에서 초기화할 수 있어야 클래스가 int와 대등한 타입이 될 수 있다.
그러나 C++은 객체에 대해 단순 타입에 적용되는 선언 및 초기화 문법을 제공하지 않는다. Position Here=30,10,'A' 따위의 문법은 불가능하다. 왜냐하면 클래스가 몇 개의 멤버를 가질지도 모르고 각 멤버의 타입도 각각 다르므로 모든 클래스에 적용되는 일반적인 문법을 정의할 수 없기 때문이다. 멤버의 수와 타입이 가변적이므로 단순한 대입 형태의 초기화는 불가능하며 초기화를 전담하는 별도의 함수가 필요하다.
객체를 초기화하는 이 특별한 함수를 생성자(Constructor)라고 부른다. 생성자는 클래스 스스로 자신을 초기화하는 방법을 정의하며 클래스를 기본 타입과 동등하게 만드는 언어적 장치이다. 생성자는 객체 초기화라는 한가지 일만 하며 컴파일러에 의해 호출되므로 이름이 고정적으로 정해져 있다. 생성자의 이름은 항상 클래스의 이름과 동일하며 필요할 경우 초기화에 사용할 인수를 받아 들일 수는 있지만 리턴값은 가질 수 없다. Position 클래스에 생성자를 추가해 보자.
예 제 : Constructor
|
#include <Turboc.h>
class Position
{
private:
int x;
int y;
char ch;
public:
x=ax;
y=ay;
ch=ach;
}
void OutPosition() {
gotoxy(x, y);
putch(ch);
}
};
void main()
{
Position Here(30,10,'A');
Here.OutPosition();
}
클래스 이름이 Position이므로 생성자 함수의 이름도 똑같이 Position이다. Position 생성자는 세 개의 인수를 전달받아 대응되는 멤버 변수에 대입함으로써 객체를 초기화한다. 멤버에 값을 대입하는 것이 본연의 임무이므로 생성자의 본체는 통상 단순한 대입문으로 구성된다.
Position 클래스가 생성자를 정의하고 있으므로 main 함수의 코드는 단 두 줄만 있으면 된다. Here 객체 선언문 뒤의 괄호안에 멤버의 초기값들이 나열되어 있는데 이 값들이 생성자의 형식 인수로 전달된다. 생성자에 의해 Here의 (x, y)는 (30,10)으로 초기화되고 ch는 'A'문자로 초기화된다. OutPosition 함수를 호출하면 초기화된대로 (30,10) 좌표에 'A' 문자가 출력될 것이다.
생성자 호출
생성자는 객체가 생성될 때 컴파일러에 의해 자동으로 호출된다. 사용자는 객체 선언문의 뒤쪽에 생성자로 전달될 인수를 명시함으로써 생성자를 호출하는데 두 가지 방법이 있다.
① 암시적인 방법 : Position Here(30,10,'A');
② 명시적인 방법 : Position Here=Position(30,10,'A');
컴파일러에 따라 이 두 문장을 처리하는 내부적인 동작은 조금 달라질 수 있지만 객체를 초기화하는 효과는 동일하다. 암시적 방법의 경우 객체를 위한 메모리를 할당하고 생성자를 호출하여 할당된 메모리를 초기화한다. 명시적인 방법은 두 가지 형태로 처리되는데 암시적 방법과 같은 식으로 초기화하는 컴파일러도 있고 이름없는 임시 객체를 먼저 생성한 후 대입하는 컴파일러도 있다. 어쨌든 객체가 초기화되는 것은 마찬가지이므로 간단하고 직관적인 ①번 형식을 더 많이 사용한다.
생성자의 인수
생성자가 객체를 초기화하기 위해서는 멤버의 모든 초기값을 인수로 전달받아야 한다. 그래서 생성자의 형식 인수 목록은 보통 멤버 목록과 일치하는 경우가 많은데 이때 형식 인수 이름이 멤버 이름과 같아서는 곤란하다. 다음 생성자 코드를 보자.
Position(int x, int y, char ch) {
x=x;
y=y;
ch=ch;
}
형식 인수의 이름이 멤버의 이름과 똑같이 되어 있는데 지역변수가 전역이나 멤버보다 더 우선인 범위 규칙에 의해 에러는 발생하지 않는다. 하지만 이 경우 함수 본체에서 참조하는 x, y, ch는 객체의 멤버 변수가 아니라 형식 인수이며 자신에게 자신의 값을 대입하는 아무 의미없는 코드가 된다. 생성자 본체에서 전달받은 인수와 초기화할 멤버의 이름을 구분해야 하는데 다음과 같은 여러 가지 방법을 쓸 수 있다.
① 형식 인수에 일정한 접두를 붙여 멤버 이름과 구분되도록 한다. 예제의 경우 ax, ay, ach로 접두 a를 붙였는데 여기서 a는 Argument의 머리글자이다. 접두를 붙이든 접미를 붙이든 아무튼 멤버 변수의 이름과 구분되어야 한다. 완전히 다른 이름을 붙이면 문법적으로는 문제가 없지만 인수와 멤버의 대응 관계를 파악하기 어려우므로 접두를 붙여 짝을 쉽게 찾을 수 있도록 하는 작전이다.
② 멤버 이름을 작성하는 특별한 규칙을 정하고 이 규칙대로 멤버의 이름을 짓는다. MFC의 경우 m_라는 접두를 클래스의 멤버에 일일이 붙이는데 이 방식대로 Position 클래스를 작성한다면 멤버의 이름은 m_x, m_y, m_ch가 된다. 멤버 이름에 특별한 규칙이 적용되므로 생성자에서는 m_을 뺀 x, y, ch를 형식 인수로 사용할 수 있다. 멤버마다 m_를 일일이 붙이는 것이 조금 귀찮기는 하지만 명칭만으로 멤버임을 구분할 수 있어 가독성에는 굉장히 유리한 방법이다.
③ 형식 인수 이름과 멤버 이름을 같이 쓰되 함수의 본체에서 멤버 변수를 참조할 때 범위 연산자를 사용한다. 범위 연산자는 지역변수와 클래스의 멤버를 구분할 때도 사용할 수 있다.
Position(int x, int y, char ch) {
Position::x=x;
Position::y=y;
Position::ch=ch;
}
그냥 x는 인수 x를 의미하는 것이고 Position::x는 Position 클래스에 소속된 멤버 x를 의미한다. 어쨌든 중요한 것은 생성자의 형식 인수와 멤버 변수의 이름이 구분되도록 하는 것이다. 이 외에 this라는 특별한 포인터를 사용하는 방법도 있는데 this에 대해서는 다음에 배우게 될 것이다. 자신이 편하다고 생각하는 방법을 사용하되 일관성있게 적용하는 것이 좋다. 이 책은 주로 첫 번째 방법을 애용한다.
생성자 오버로딩
좀 특수한 면이 있기는 하지만 생성자도 분명히 함수의 일종이다. 그러므로 오버로딩이 가능하며 디폴트 인수를 사용할 수도 있고 인라인으로 선언할 수도 있다. 객체를 초기화하는 방법이 여러 가지 존재할 경우 원하는만큼 생성자를 복수 개 정의할 수 있으며 객체를 선언할 때 초기화 방법을 선택할 수 있다. 물론 이 경우 인수의 개수나 타입이 달라야 한다는 오버로딩 규칙을 만족해야 한다.
예 제 : ConstructOverload
|
#include <Turboc.h>
class Position
{
private:
int x;
int y;
char ch;
public:
Position(char ach) {
x=random(80);
y=random(24);
ch=ach;
}
Position(int ax, int ay, char ach='S') {
x=ax;
y=ay;
ch=ach;
}
void OutPosition() {
gotoxy(x, y);
putch(ch);
}
};
void main()
{
randomize();
Position Here(30,10,'A');
Position There(40,5);
Position Where('K');
Here.OutPosition();
There.OutPosition();
Where.OutPosition();
}
이 예제의 Position 클래스는 두 개의 생성자를 정의하고 있다. Position(char) 생성자는 ch에 대한 초기값만 전달받으며 좌표는 난수로 선택하도록 하고 Position(int,int,char) 생성자는 세 개의 인수를 전달받아 모든 멤버를 초기화하되 마지막 인수에는 디폴트 값 'S'를 적용한다.
오버로딩된 함수는 호출부의 인수 목록으로 호출할 함수가 선택되는데 생성자도 마찬가지이다. 객체를 선언할 때 어떤 인수가 전달되었는가에 따라 적절한 생성자가 선택된다. main에서 세 개의 Position 객체를 생성하는데 각각 어떤 생성자에 의해 초기화되는지 보자. Here 객체는 모든 인수가 전달되었으므로 Position(int,int,char) 생성자에 의해 인수값대로 초기화된다. There 객체도 같은 생성자로 초기화하되 마지막 인수가 생략되었으므로 디폴트값 'S'가 적용될 것이다. Where는 char형 인수 하나만 전달되었으므로 Position(char) 생성자가 호출되며 x, y 멤버는 난수에 의해 무작위로 초기화될 것이다.
컴파일러는 객체 선언문의 인수 목록을 보고 호출할 생성자를 결정하는데 만약 해당되는 생성자가 없을 경우는 에러로 처리된다. Position A; 선언문의 경우 인수를 취하지 않는 Position() 생성자를 호출하는데 이런 원형을 가지는 생성자가 정의되어 있지 않으므로 컴파일러는 A객체를 생성할 수 없다. Position B("아무데나"); 선언문도 문자열을 받아들이는 생성자가 없으므로 마찬가지로 에러이다. 그러나 Position C(12.34, 22.5); 호출문은 산술 변환 규칙에 의해 Position(int,int,char); 와 호환되므로 가능하다.
객체가 모델링하는 실세계의 사물들은 int나 double 같은 기존 타입보다 훨씬 더 복잡하기 때문에 초기화 방법이 복수 개 존재하는 경우가 많다. 예를 들어 원을 표현하는 방법만 해도 중심점과 반지름을 지정하는 방법과 외접 사각형의 좌표로 지정하는 방법 등 최소한 두 가지가 존재한다.
색상의 경우도 모델에 따라 RGB로 표현할 수 있고 CMYK나 HSB 방식으로 표현할 수도 있다. 그래서 C++은 생성자의 오버로딩을 지원하며 선언 단계에서 원하는 방법대로 초기화하도록 허락한다.
26-1-나.파괴자
생성자는 주로 멤버 변수의 값을 원하는 값으로 대입하는 작업을 하지만 그 외 객체가 동작하는데 필요한 모든 초기화 처리를 담당하기도 한다. 예를 들어 네트워크 통신을 하는 객체의 경우 이 객체가 동작하려면 네트워크 연결을 먼저 해야 하며 데이터 베이스를 액세스하는 객체라면 서버와 연결해야 하는데 이런 동작 환경 초기화도 생성자의 임무에 속한다. 생성자를 이런 용도로 사용하는 예는 이미 앞에서 본 적이 있는데 RandNumOop 예제의 RandNum생성자의 코드를 보자.
class RandNum
{
public:
RandNum() { randomize(); }
RandNum 객체는 난수를 캡슐화하는데 난수가 무작위로 생성되려면 randomize 함수로 난수 발생기를 먼저 초기화해야 한다. 이 작업을 생성자가 담당함으로써 객체를 생성하는 즉시 난수 발생기가 초기화된다. 이외에 필요한 버퍼를 동적으로 할당하거나 객체가 사용하는 파일을 오픈하는 일도 생성자의 몫이다. 요컨데 생성자는 객체가 제대로 동작하기 위한 모든 처리를 담당하는 함수이다.
생성자가 객체 자체의 초기화외에 외부 환경까지 초기화하기 때문에 객체가 사라질 때 반대의 처리를 할 함수도 필요하다. 객체나 메모리 또는 프로그램 등 컴퓨터안에서 움직이는 모든 것들은 항상 자신이 생성되기 전의 상태로 환경을 돌려 놓을 의무가 있다. 객체가 통신을 위해 네트워크 연결을 했다면 자신이 사라질 때 이 연결을 끊어야 하며 할당된 메모리는 해제해야 한다.
이러한 뒷처리를 하는 특별한 멤버 함수를 파괴자(Destructor)라고 하며 객체가 소멸될 때 컴파일러에 의해 자동으로 호출된다. 파괴자의 이름은 클래스 이름앞에 ~를(tilde라고 읽는다) 붙인 것으로 고정되어 있으며 인수와 리턴값은 가지지 않는다. 다음 예제는 파괴자의 예를 보여 준다.
예 제 : Person1
|
#include <Turboc.h>
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() {
delete [] Name;
}
void OutPerson() {
printf("이름 : %s 나이 : %d\n",Name,Age);
}
};
void main()
{
Person Boy("을지문덕",25);
Boy.OutPerson();
}
Person 클래스는 사람을 표현하는데 이름과 나이를 멤버 변수로 가진다. 나이는 정수값이므로 int형이면 충분하지만 이름의 경우는 문자열이므로 그 길이를 예측할 수 없다. 대한민국의 사람 이름은 기껏해야 4자이므로 char Name[9]; 정도면 일단 충분하겠지만 외국인이나 별나게 긴 이름을 좋아하는 사람의 경우는 4자 이상의 이름을 가지는 경우도 있다.
그래서 고정된 길이의 버퍼를 사용하는 것은 위험하며 입력되는 이름의 길이만큼 버퍼를 동적으로 할당해야 한다. 생성자는 인수로 전달된 이름의 길이만큼 Name 버퍼를 동적으로 할당하여 이 버퍼에 이름을 저장하는데 이런 식이면 "아리따운 박미영"이나 "레오나르도 디카프리오"같은 긴 이름도 얼마든지 표현할 수 있다. 생성자에서 별도의 버퍼를 직접 할당했으므로 Name이 가리키는 버퍼는 객체 내부에 있지 않고 외부의 힙에 따로 존재한다. 객체는 Name 멤버를 통해 이름이 저장되어 있는 메모리 주소를 가리키고 있을 뿐이다.
이 상태에서 객체가 파괴되면 객체가 점유하고 있던 모든 메모리는 자동으로 해제된다. Boy객체는 main 함수의 지역변수이므로 main이 종료될 때 파괴되는데 이때 Name멤버와 Age멤버가 차지하고 있는 8바이트가 해제될 것이다. Name 멤버 자체는 해제되지만 Name이 가리키고 있는 힙의 메모리는 동적으로 할당된 것이므로 자동으로 해제되지 않는다. 그래서 객체가 파괴될 때 반드시 직접 해제해야 하며 이런 처리를 하는 함수가 바로 파괴자이다. 예제의 ~Person 파괴자는 Name 멤버가 차지하고 있는 메모리를 해제한다. Name이 new [ ]로 할당되었으므로 해제할 때는 delete [ ]를 쓴다.
파괴자는 객체가 사라질 때 컴파일러에 의해 자동으로 호출되는데 객체가 바꿔 놓은 환경을 원래대로 돌려 놓거나 할당한 자원을 회수하는 역할을 한다. Position 클래스는 파괴될 때 특별히 할 일이 없으므로 파괴자가 불필요하지만 Person 클래스는 생성자에서 메모리를 동적으로 할당하므로 이 메모리를 해제할 파괴자가 반드시 필요하다. 만약 파괴자가 정의되어 있지 않다면 할당된 메모리를 아무도 해제하지 않으므로 메모리 누수가 발생할 것이다.
생성자/소멸자의 특징
- 이름이 없다.
- 리턴이 없다.
- public 속성을 갖는다.
- 소멸자는 인수가 없다.
- static, friend 로 선언 될수 없다.
- 생성자는 virtual 로 선언될 수 없다.
- 디폴트 소멸자, 디폴트 생성자
26-1-라.객체의 동적 생성
실행중에 객체를 동적으로 생성할 때는 new 연산자를 사용한다. new 연산자는 객체를 위한 메모리를 할당한 후 생성자를 호출하므로 동적 할당문에 생성자가 요구하는 인수를 전달해야 한다. 앞의 Person1 예제의 main 함수에 다음 코드를 추가해 보자.
void main()
{
Person Boy("을지문덕",25);
Boy.OutPerson();
Person *pGirl;
pGirl=new Person("신사임당",19);
pGirl->OutPerson();
delete pGirl;
}
new 연산자로 Person형의 객체를 동적 생성하되 생성자로 ("신사임당", 19)의 인수를 전달했다. new 연산자는 Person 클래스의 크기만큼 메모리를 할당하고 Person(char *,int) 생성자를 호출하여 이 객체를 초기화할 것이다. 생성자의 new 연산자는 인수로 전달받은 문자열을 저장할만큼 메모리를 동적 할당하여 Name 멤버에 대입한다. 초기화가 완료된 후 새로 생성된 객체의 포인터가 리턴되는데 이 포인터를 Person *형의 pGirl이라는 변수에 대입했다. 다소 생소해 보일지 모르겠지만 복잡하게 생각할 필요없다. 다음 코드와 개념적으로 똑같다.
int *pi;
pi=new int(1234);
cout << *pi;
delete pi;
타입이 int가 아닌 사용자 정의형이라는 것 외에는 똑같은 문장이다. pGirl 포인터가 동적으로 생성된 Person 객체를 가리키고 있으므로 이 포인터를 사용하여 객체를 프로그래밍할 수 있다. pGirl->OutPerson() 은 pGirl이 가리키는 객체의 정보를 화면으로 출력할 것이다. 다 사용한 객체는 delete 연산자로 파괴하는데 이 연산자는 먼저 파괴자를 호출한다. 파괴자는 생성자가 할당해 놓은 메모리를 해제하며 delete 연산자는 객체 그 자체를 메모리에서 해제한다. pGirl 객체의 일생을 그림으로 그려 보면 다음과 같다.
객체 그 자체도 힙에 생성되지만 객체가 사용하는 메모리도 힙에 생성된다. 동적 할당의 대상이 객체일 때는 반드시 생성자를 호출하는 new 연산자를 사용해야 한다. 객체가 생성 및 파괴될 때 어떤 일들이 일어나는지 확인해 보기 위해 생성자와 파괴자에 printf로 간단한 메시지를 출력해 보자.
Person(const char *aName, int aAge) {
Name=new char[strlen(aName)+1];
strcpy(Name,aName);
Age=aAge;
printf("%s 객체의 생성자가 호출되었습니다.\n",Name);
}
~Person() {
printf("%s 객체가 파괴되었습니다.\n",Name);
delete [] Name;
}
실행 결과는 다음과 같다.
을지문덕 객체의 생성자가 호출되었습니다.
이름 : 을지문덕 나이 : 25
신사임당 객체의 생성자가 호출되었습니다.
이름 : 신사임당 나이 : 19
신사임당 객체가 파괴되었습니다.
을지문덕 객체가 파괴되었습니다.
정적으로 생성되는 객체이든, 동적으로 생성되는 객체이든 생성자와 파괴자가 모두 호출된다는 것을 확인할 수 있다. 어떤 함수가 언제 호출되는지 정확한 시점과 회수를 알고 싶으면 이런 식으로 문자열을 출력해 보면 된다. 이 코드를 수정하여 malloc으로 객체를 생성해 보자.
Person *pGirl;
pGirl=(Person *)malloc(sizeof(Person));
pGirl->OutPerson();
free(pGirl);
이렇게 되면 객체를 위한 메모리만 할당될 뿐 객체가 사용하는 메모리는 할당되지 않는다. malloc은 단순한 메모리 할당 함수일 뿐이므로 지정한 바이트수만큼 메모리만 할당하며 생성자를 호출하거나 하지는 않는다. pGirl이 가리키는 곳에는 Person 클래스의 크기만큼 메모리가 할당되어 있기는 하지만 Name이나 Age는 쓰레기값을 가지고 있을 것이다. OutPerson은 이 두 값을 화면으로 출력하는데 Name이 가리키는 메모리 영역이 어디인지 알 수 없으므로 다운될 확률이 아주 높다.
malloc이 생성자를 호출하지 않는 것과 마찬가지로 free는 파괴자를 호출하지 않는다. 객체를 동적 할당할 때는 반드시 new/delete 연산자를 사용하여 생성자와 파괴자를 적절하게 호출하도록 해야 한다. malloc/free는 단순 메모리를 할당할 때만 쓸 수 있는데 Person 생성자에서 Name을 위한 메모리를 할당할 때는 이 함수들을 사용할 수 있다. 이름 문자열 저장을 위한 버퍼를 malloc으로 할당하도록 수정해 보자.
예 제 : Personmalloc
|
#include <Turboc.h>
class Person
{
private:
char *Name;
int Age;
public:
Person(const char *aName, int aAge) {
Name=(char *)malloc(strlen(aName)+1);
strcpy(Name,aName);
Age=aAge;
}
~Person() {
free(Name);
}
void OutPerson() {
printf("이름 : %s 나이 : %d\n",Name,Age);
}
};
void main()
{
Person Boy("을지문덕",25);
Boy.OutPerson();
Person *pGirl;
pGirl=new Person("신사임당",19);
pGirl->OutPerson();
delete pGirl;
}
Name 버퍼는 단순한 문자열에 불과하므로 이 배열을 할당할 때는 굳이 new, delete를 사용하지 않아도 무관하며 별 문제가 없다. 그러나 두 방법을 섞어서 쓰다 보면 실수할 가능성이 있으므로 객체를 프로그래밍할 때는 가급적이면 new, delete로 할당 함수를 통일하는 것이 좋다.
여기까지의 코드만으로 보면 이 예제는 완벽하다. 그러나 Person 클래스는 아직 불완전하며 완전한 타입이 되기 위해서는 복사 생성자와 대입 연산자까지 재정의해야 한다. 이런 주제에 대해서는 다음에 자세하게 다루게 될 것이며 새로운 문법을 배울 때마다 이 예제를 계속 확장해 볼 것이다. 따라서 이후의 월활한 학습을 위해 Person 클래스를 잘 기억해 두어야 한다.
26-2.여러 가지 생성자
26-2-가. 디폴트 생성자
디폴트 생성자(또는 기본 생성자라고도 한다)란 인수를 가지지 않는 생성자이다. 생성자는 오버로딩이 가능하므로 여러 개를 둘 수 있는데 그 중 인수가 없는 생성자를 디폴트 생성자라고 부른다. 즉 인수 목록이 void인 생성자인데 Position 클래스의 경우 디폴트 생성자의 원형은 Position()이 된다. 다음 예제의 Position 클래스는 디폴트 생성자 하나만 정의하고 있다.
예 제 : DefConstructor
|
#include <Turboc.h>
class Position
{
private:
int x;
int y;
char ch;
public:
Position() {
x=0;
y=0;
ch=' ';
}
void OutPosition() {
if (ch != ' ') {
gotoxy(x, y);
putch(ch);
}
}
};
void main()
{
Position Here;
Here.OutPosition();
}
디폴트 생성자는 호출부에서 어떤 값으로 초기화하고 싶은지를 전달하는 수단인 인수가 없다. 인수를 받아들이지 않기 때문에 객체의 멤버에 의미있는 어떤 값을 대입하지는 못하며 주로 모든 멤버를 0이나 -1 또는 NULL이나 빈문자열로 초기화한다. 여기서 0이라는 값은 실용적인 의미를 가지는 값이라기보다는 단순히 아직 초기화되지 않았음을 분명히 표시하는 역할을 한다. 어떤 값인지 알지도 못하는 쓰레기값보다는 그래도 0이라도 대입해 놓는 것이 더 나은데 이렇게 하면 멤버 함수에서 이 값을 사용하기 전에 초기화 되어 있는지를 점검할 수 있기 때문이다.
if (ptr == NULL) { ... }
if (value == 0) { ... }
디폴트 생성자가 포인터 변수를 NULL로 초기화해 놓으면 멤버 함수가 이 변수를 사용하기 전에 NULL인지 조사해 보고 NULL이면 그때 초기화를 할 수 있다. 즉 디폴트 생성자의 임무는 쓰레기를 치우는 것이며 멤버의 초기화는 이 멤버를 사용하는 멤버 함수가 호출될때까지 연기된다. 위 예제의 Position() 디폴트 생성자는 x, y는 0으로 초기화하고 ch에는 공백 문자를 대입하며 OutPosition 함수는 ch가 공백 문자를 가질 때 이 객체가 아직 초기화되지 않은 것으로 판단하고 문자 출력을 하지 않는다. 디폴트 생성자가 있는 객체를 선언할 때는 다음과 같은 여러 가지 방법을 사용할 수 있다.
① Position Here;
② Position Here=Position();
③ Position *pPos=new Position;
④ Position *pPos=new Position();
⑤ Position Here();
①번 형식이 가장 간단하며 예제에서 사용한 방법이다. 생성자에게 전달할 인수가 없으므로 타입 다음에 객체 이름만 밝히면 된다. 기본 타입의 int i; 선언문과 형식이 동일하다. ②번 형식은 디폴트 생성자를 명시적으로 호출하는 구문인데 효과는 동일하다. ③, ④번은 객체를 동적으로 생성할 때 new연산자와 함께 사용하는 방법인데 ③번이 더 일반적이다.
그러나 ⑤번 형식은 허용되지 않는다. 왜냐하면 이 선언문은 Position 객체를 리턴하고 인수를 가지지 않는 Here 함수의 원형을 선언하는 것이지 객체 선언문이 아니기 때문이다. 생성자로 전달할 인수가 없으면 아예 괄호도 없어야 한다. 일반 함수는 인수가 없을 때 빈 괄호를 써 함수임을 분명히 표시하지만 객체 선언문의 경우는 반대로 생성자의 인수가 없을 때 괄호를 생략해 함수가 아님을 분명히 해야 한다. 잘 이해가 안되고 순간적으로 헷갈린다면 정수형으로 바꿔 생각해 보자.
int func; // 이건 변수
int func(); // 요건 함수
만약 클래스가 생성자를 전혀 정의하지 않으면 어떻게 될까? 이 경우 컴파일러가 자동으로 디폴트 디폴트 생성자(그러니까 컴파일러가 기본적으로 정의하는 디폴트 생성자)를 만든다. 컴파일러가 만들어주는 디폴트 생성자는 아무 것도 하지 않는 빈 함수이다. 이 때 객체의 초기화 방식은 일반 변수와 같은 규칙이 적용되는데 전역이나 정적 객체라면 모든 멤버가 0으로 초기화되고 지역 객체라면 초기화되지 않는 쓰레기값을 가진다.
생성자가 없을 경우 컴파일러가 디폴트 생성자를 만들기 때문에 생성자를 전혀 정의하지 않아도 객체를 선언할 수 있는 것이다. 위 예제에서 Position() 디폴트 생성자를 삭제하면 컴파일러가 내부적으로 다음과 같은 디폴트 생성자를 만들 것이다.
Position()
{
}
비록 아무 것도 하지는 않지만 생성자가 있으므로 Position Here; 선언문으로 Here 객체를 선언할 수 있다. 그러나 이 객체는 쓰레기값을 가지고 있기 때문에 OutPosition이 어떤 동작을 할 것인지는 예측할 수 없다. 일반적으로 예측할 수 없는 동작은 항상 말썽의 소지가 되며 이런 잠재적인 말썽의 소지를 없애기 위해 디폴트 생성자를 직접 정의하고 모든 멤버의 쓰레기를 치우는 것이다.
컴파일러가 디폴트 생성자를 만드는 경우는 클래스가 생성자를 전혀 정의하지 않을 때 뿐이다. 다른 생성자가 하나라도 정의되어 있으면 컴파일러는 디폴트 생성자를 만들지 않는다. 다음 코드를 보자.
class Position
{
public:
int x;
int y;
char ch;
Position(int ax) { x=ax; }
void OutPosition() { ... }
};
정수 하나를 인수로 취하는 생성자가 정의되어 있으므로 이 클래스는 디폴트 생성자를 가지지 않는다. 이 경우 Position Here; 선언문은 적절한 생성자를 찾을 수 없으므로 에러로 처리될 것이다. 별도의 생성자를 제공했다는 것은 클래스를 만든 사람이 이 객체는 이런 식으로 초기화해야 한다는 것을 분명히 명시한 것이므로 컴파일러는 이 규칙을 어긴 코드에 대해 사정없이 에러로 처리한다. 이 객체는 개발자의 의도에 따라 반드시 Position Here(12); 형식으로 생성해야 한다.
만약 Position Here; 형태로 꼭 객체를 선언하고 싶다면 Position(int) 생성자를 없애 컴파일러가 디폴트 생성자를 만들도록 내 버려 두든가 아니면 Position() 디폴트 생성자를 오버로딩해야한다. 생성자가 인수를 가지고 있더라도 디폴트 인수 기능에 의해 디폴트 생성자가 되는 경우도 있다. 다음과 같은 원형을 가지는 생성자는 인수없이도 호출할 수 있으므로 디폴트 생성자를 겸한다.
Position(int ax=0, int ay=0, char ach=' ')
디폴트 생성자가 없는 클래스는 객체 배열을 선언할 수 없다. 왜 그런지 다음 예제로 이유를 알아보자.
예 제 : NoDefCon
|
#include <Turboc.h>
class Position
{
public:
int x;
int y;
char ch;
Position(int ax, int ay, char ach) {
x=ax;
y=ay;
ch=ach;
}
void OutPosition() {
gotoxy(x, y);
putch(ch);
}
};
void main()
{
Position There[3];
}
이 예제의 Position 클래스는 디폴트 생성자를 정의하지 않으며 세 개의 인수를 취하는 생성자만 정의되어 있다. 개발자가 별도의 생성자를 정의했으므로 컴파일러는 디폴트 생성자를 만들지 않는다. 따라서 Position형의 객체를 만들려면 Position A(1,2,'A'); 식으로 생성자에게 세 개의 인수를 전달해야 한다. 그렇다면 main의 Position There[3]; 선언문은 어떻게 처리될까?
Position형의 객체 3개를 배열로 생성하되 이때 각 객체의 생성자가 호출될 것이다. 그러나 선언문에 인수가 없기 때문에 호출할만한 생성자를 찾을 수 없으며 에러로 처리된다. Position There[3]; 선언문이 처리되려면 인수를 취하지 않는 디폴트 생성자(컴파일러가 만든 것이든 개발자가 직접 정의한 것이든)가 반드시 있어야 하는 것이다. 다음과 같은 선언문이 가능하리라 생각해 볼 수도 있다.
Position There[3]={{1,2,'x'},{3,4,'y'},{5,6,'z'}};
구조체 배열처럼 ={ } 다음에 각 배열 요소의 초기값을 나열하는 형식이다. {1,2,'x'} 초기식에 있는 값을 클래스 선언문에 나타나는 멤버의 순서대로 대입하면 될 것처럼 보인다. 객체 배열을 초기화할 때도 이런 문법이 지원된다면 좋겠지만 이 문장은 에러로 처리된다. 왜 컴파일러가 객체 배열에 대한 이런 편리한 초기식을 지원하지 못하는지 생각해 보자.
객체는 단순히 정보의 집합인 구조체보다는 훨씬 더 복잡하기 때문에 단순한 대입만으로는 초기화할 수 없다. 생성 단계에서 둘 이상의 입력값을 계산한 결과가 초기값이 될 수도 있고 Person 예제처럼 인수의 길이만큼 메모리를 동적으로 할당해야 하는 경우도 있다. 또한 멤버가 프라이비트 영역에 있을 경우 외부 선언문에서 함부로 멤버값을 변경하는 것도 허락되지 않는다. 이런 능동적인 동작을 하려면 결국 객체 초기화를 위해 생성자가 호출되어야 하는 것이다.
그렇다면 초기식의 값을 그대로 생성자의 인수로 전달하면 되지 않을까? 초기식에 {1,2,'x'}라고 되어 있으니 Position(1,2,'x') 생성자를 호출하면 일단 될 것처럼 보이지만 이것도 불가능하다. 왜냐하면 생성자가 반드시 모든 멤버를 선언된 순서대로 다 받아들여야 한다는 제약이 없기 때문이다. Position(char ach, int ax, int ay) 요런 식으로 생성자가 정의되어 있다면 컴파일러가 초기식의 값과 생성자 인수와의 대응관계를 잘못 판단하게 될 것이고 객체는 제대로 초기화되지 않는다.
그래서 컴파일러는 애매한 초기식으로부터 대충 비슷해 보이는 생성자를 호출하는 쓸데없는 서비스를 하기보다는 차라리 에러로 처리하는 것이 더 깔끔하다고 생각하는 것이다. 만약 객체의 배열을 선언하면서 각 객체를 꼭 초기화하려면 다음과 같이 ={ }괄호안에서 생성자를 일일이 호출해야 한다.
void main()
{
int i;
Position There[3]={Position(1,2,'x'),Position(3,4,'y'),Position(5,6,'z')};
for (i=0;i<3;i++) {
There[i].OutPosition();
}
}
이 선언문은 초기식에서 명시적으로 생성자를 호출했고 생성자로 전달되는 인수의 순서를 컴파일러가 분명하게 알 수 있으므로 문법적으로 문제도 없고 애매하지도 않다. 객체 배열을 선언하면서 초기화할 때는 이 방법이 정석이며 초기식없이 선언만 하려면 반드시 디폴트 생성자가 정의되어 있어야 한다.
생성자가 없을 때 컴파일러가 디폴트를 만드는 것처럼 파괴자의 경우도 디폴트가 있다. 컴파일러가 만드는 디폴트 파괴자도 생성자의 경우와 마찬가지로 아무 일도 하지 않는 빈 함수이다. 그래서 뒷정리를 할 필요가 없는 클래스라면 디폴트 파괴자를 그냥 사용하는 것도 가능하다. 즉, 파괴자가 없어도 된다는 얘기인데 사실 파괴자는 필요없는 경우가 훨씬 더 많다. 생성자가 특별한 처리를 하지 않고 단순히 멤버 변수에 값만 대입한다면 뒷정리를 할 필요가 없다. Position은 파괴자가 전혀 불필요한 클래스이다.
6-2-나.복사 생성자
복사 생성자는 지금까지의 평이한 내용에 비해 약간 난이도가 있는 내용이므로 정신을 집중해서 읽을 필요가 있다. 변수를 선언할 때 = 구분자 다음에 상수로 초기값을 지정할 수 있으며 이미 생성되어 있는 같은 타입의 다른 변수로도 초기화할 수 있다. 다음은 가장 간단한 타입인 정수형의 예이다.
int a=3;
int b=a;
정수형 변수 a는 선언됨과 동시에 3으로 초기화되었다. 그리고 동일한 타입의 정수형 변수 b는 선언과 동시에 a로 초기화되었다. 결국 두 변수는 모두 3의 값을 가지게 될 것이다. 너무 너무 상식적인 코드이며 이런 초기화는 실수형이나 문자형, 구조체 등에 대해서도 똑같이 허용된다. 클래스가 int와 동일한 자격을 가지는 타입이 되기 위해서는 이미 생성되어 있는 같은 타입의 객체로부터 초기화될 수 있어야 한다. 객체에 대해서도 과연 이런 초기화가 성립할 수 있는지 Position 객체로 테스트해 보기 위해 Constructor 예제에 다음 코드를 작성해 보자.
Position Here(30,10,'A');
Position There=Here;
There.OutPosition();
Here 객체가 먼저 (30,10) 위치의 문자 'A'를 가리키도록 초기화되었으며 이어서 There객체는 선언과 동시에 Here객체로 초기화되었다. 이때 멤버별 복사에 의해 There는 Here의 모든 멤버값을 그대로 복사받으며 두 객체는 완전히 동일한 값을 가지게 된다. Position 객체가 내부에 모든 정보를 포함하고 있기 때문에 이런 초기화는 전혀 문제가 없다. 그렇다면 모든 객체에 대해 이런 초기화가 가능한지 Person 객체로도 테스트해 보자. Person1 예제의 main 함수에 다음 테스트 코드를 작성한다.
void main()
{
Person Boy("강감찬",22);
Person Young=Boy;
Young.OutPerson();
}
이 코드는 정상적으로 컴파일되며 실행도 되지만 종료할 때 파괴자에서 실행중 에러가 발생하는데 왜 그런지 보자. Young 객체가 Boy객체로 초기화될 때 멤버별 복사가 발생하며 Young의 Name멤버가 Boy의 Name과 동일한 번지를 가리키고 있다. 정수형인 Age끼리 값이 복사되는 것은 아무 문제가 없지만 포인터끼리의 복사는 문제가 된다. Young이 초기화된 직후의 메모리 상황을 그림으로 그려보면 다음과 같으며 두 객체가 힙에 동적 할당된 메모리를 공유하고 있는 모양이다.
이런 상태에서 Young.OutPerson이나 Boy.OutPerson 함수 호출은 아주 정상적으로 실행된다. 그러나 두 객체가 같은 메모리를 공유하고 있기 때문에 한쪽에서 Name을 변경하면 다른 쪽도 영향을 받게 되어 서로 독립적이지 못하다. 이 객체들이 파괴될 때 문제가 발생하는데 각 객체의 파괴자가 Name 번지를 따로 해제하기 때문이다. new는 Boy의 생성자에서 한 번만 했고 delete는 각 객체의 파괴자에서 두 번 실행하기 때문에 이미 해제된 메모리를 다시 해제하려고 시도하므로 실행중 에러가 된다. 정수형은 어떤지 보자.
int a=3;
int b=a;
b=5;
b가 생성될 때 a의 값으로 초기화되어 a와 b는 같은 값을 가진다. 그러나 이는 어디까지나 초기화될 때 잠시만 같을 뿐이지 두 변수는 이후 완전히 독립적으로 동작한다. b에 5를 대입한다고 해서 a가 이 대입의 영향을 받지 않으며 a에 무슨 짓을 하더라도 b를 어찌할 수는 없다. 정수형의 복사 생성이 이처럼 독립적이므로 사용자 정의형도 이와 똑같이 복사 생성을 할 수 있어야 한다.
Person Young=Boy; 선언문에 의해 Young은 Boy의 멤버값을 복사받지만 이 때의 복사는 포인터를 그대로 복사하는 얕은 복사이다. 따라서 Young은 일시적으로 Boy와 같은 값을 가지지만 Boy의 Name을 빌려서 정보를 표현하는 불완전한 객체이며 독립적이지 못하다. 이 문제를 해결하려면 초기화할 때 얕은 복사를 해서는 안되며 깊은 복사를 해야 하는데 이때 복사 생성자가 필요하다. 얕은 복사가 문제의 원인이었으므로 깊은 복사를 하는 복사 생성자를 만들어 해결할 수 있다. 다음 예제는 Person1예제를 수정하여 Person 클래스에 복사 생성자를 추가한 것이다.
예 제 : Person2
|
#include <Turboc.h>
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;
}
void OutPerson() {
printf("이름 : %s 나이 : %d\n",Name,Age);
}
};
void main()
{
Person Boy("강감찬",22);
Person Young=Boy;
Young.OutPerson();
}
복사 생성자는 자신과 같은 타입의 다른 객체에 대한 레퍼런스를 전달받아 이 레퍼런스로부터 자신을 초기화한다. Person복사 생성자는 동일한 타입의 Other를 인수로 전달받아 자신의 Name에 Other.Name의 길이만큼 버퍼를 새로 할당하여 복사한다. 새로 메모리를 할당해서 내용을 복사했으므로 이 메모리는 완전한 자기 것이며 안전하게 따로 관리할 수 있다. Age는 물론 단순 변수이므로 값만 대입받으면 된다.
컴파일러는 Person Young=Boy; 구문을 Person Young=Person(Boy);로 해석하는데 이 원형에 맞는 생성자인 복사 생성자를 호출한다. 실인수 Boy가 Person 객체이므로 Person을 인수로 받아들이는 생성자 함수를 호출할 것이다. 복사 생성자에 의해 Young은 깊은 복사를 하며 메모리에 다음과 같이 완전한 사본을 작성한다.
이제 Young과 Boy는 타입만 같을 뿐 완전히 다른 객체이고 메모리도 따로 소유하므로 각자의 Name을 마음대로 바꿀 수 있고 파괴자에서 메모리를 해제해도 문제가 없다. 복사 생성자에 의해 두 객체가 완전한 독립성을 얻은 것이다.
복사 생성자의 임무는 새로 생성되는 객체가 원본과 똑같으면서 완전한 독립성을 가지도록 하는 것이다. 만약 객체가 데이터 베이스를 사용한다면 이 클래스의 복사 생성자는 새 객체를 위한 별도의 데이터 베이스 연결을 해야 하며 독점적인 자원을 필요로 한다면 마찬가지로 별도의 자원을 할당해야 한다. 그래야 Class A=B; 선언문에 의해 A가 B에 대해 독립적으로 초기화된다.
객체가 인수로 전달될 때
같은 종류의 다른 객체로 새 객체를 선언하는 경우는 그리 흔하지 않다. 그러나 다음과 같이 함수의 인수로 객체를 넘기는 경우는 아주 흔한데 이때도 복사 생성자가 호출된다.
void PrintAbout(Person AnyBody)
{
AnyBody.OutPerson();
}
void main()
{
Person Boy("강감찬",22);
PrintAbout(Boy);
}
함수 호출 과정에서 형식 인수가 실인수로 전달되는 것은 일종의 복사생성이다. 함수 내부에서 새로 생성되는 형식인수 AnyBody가 실인수 Boy를 대입받으면서 초기화되는데 이때 복사 생성자가 없다면 AnyBody가 Boy를 얕은 복사하며 두 객체가 동적 버퍼를 공유하는 상황이 된다. AnyBody는 지역변수이므로 PrintAbout 함수가 리턴될 때 AnyBody의 파괴자가 호출되고 이때 동적 할당된 메모리가 해제된다. 이후 Boy가 메모리를 정리할 때는 이미 해제된 메모리를 참조하고 있으므로 에러가 발생할 것이다.
복사 생성자가 정의되어 있으면 AnyBody가 Boy를 깊은 복사하므로 아무런 문제가 없다. 객체가 인수로 전달될 때 뿐만 아니라 리턴값으로 돌려질 때도 복사 생성자가 호출된다. 위 테스트 코드를 Person2 예제에 작성해 놓고 실행하면 정상적으로 실행된다. 그러나 복사 생성자를 주석으로 묶어 버리면 다운된다. 함수의 인수로 사용되거나 리턴값으로 사용되는 객체는 반드시 복사 생성자를 제대로 정의해야 한다.
복사 생성자의 인수
복사 생성자의 인수는 반드시 객체의 레퍼런스여야 하며 객체를 인수로 취할 수는 없다. 만약 다음과 같이 Person형의 객체를 인수로 받아들인다고 해 보자.
Person(const Person Other)
{
Name=new char[strlen(Other.Name)+1];
strcpy(Name,Other.Name);
Age=Other.Age;
}
복사 생성자 자신도 함수이므로 실인수를 전달할 때 값의 복사가 발생할 것이다. 객체 자체를 인수로 전달하면 복사 생성자로 인수를 넘기는 과정에서 다시 복사 생성자가 호출될 것이고 이 복사 생성자는 인수를 받기 위해 또 다시 복사 생성자를 호출한다. 결국 자기가 자신을 종료조건없이 호출해대는 무한 재귀 호출이 발생할 것이며 컴파일러는 이런 상황을 방관하지 않고 에러로 처리한다.
이런 이유로 복사 생성자의 인수로 객체를 전달할 수는 없다. 그렇다면 포인터의 경우는 어떨까? 포인터는 어디까지나 객체를 가리키는 번지값이므로 한 번만 복사되며 무한 호출되지 않는다. 또한 객체가 아무리 거대해도 단 4바이트만 전달되므로 속도도 빠르다. 복사 생성자가 객체의 포인터를 전달받도록 다음과 같이 수정해 보자.
Person(const Person *Other) {
Name=new char[strlen(Other->Name)+1];
strcpy(Name,Other->Name);
Age=Other->Age;
}
Other의 타입이 Person *로 바뀌었고 본체에서 Other의 멤버를 참조할 때 . 연산자 대신 -> 연산자를 사용하면 된다. 그러나 이렇게 하면 Person Young=Boy; 선언문이 암시적으로 호출하는 생성자인 Person(Boy)와 원형이 맞지 않다. 사실 포인터를 취하는 생성자는 복사 생성자로 인정되지도 않는다. 꼭 포인터로 객체를 복사하려면 main의 객체 선언문이 Person Young=&Boy;가 되어야 하는데 그래야 Person 복사 생성자로 Boy의 번지가 전달된다. main 함수까지 같이 수정하면 정상적으로 잘 동작한다.
그러나 이는 일반적인 변수 선언문과 형식이 일치하지 않는다. 기본 타입의 복사 생성문을 보면 int i=j; 라고 하지 int i=&j;라고 선언하지는 않는다. 즉 포인터를 통한 객체 복사 구문은 C 프로그래머가 알고 있는 상식적인 변수 선언문과는 틀리다. 클래스가 기본형과 완전히 같은 자격의 타입이 되려면 int i=j; 식으로 선언할 수 있어야 한다.
그래서 객체 이름에 대해 자동으로 &를 붙이고 함수 내부에서는 전달받은 포인터에 암시적으로 *연산자를 적용하는 레퍼런스라는 것이 필요해졌다. 복사 생성자가 객체의 레퍼런스를 받으면 Young=Boy라고 써도 실제로는 포인터인 &Boy가 전달되어 속도 저하나 무한 호출없이 기본 타입과 똑같은 형식의 선언이 가능하다. 이후 공부하게 될 연산자 오버로딩에도 똑같은 이유로 레퍼런스가 활용된다. C에서는 꼭 필요치 않았던 레퍼런스라는 개념이 C++에서는 필요해진 이유가 객체의 선언문, 연산문을 기본 타입과 완전히 일치시키기 위해서이다.
복사 생성자로 전달되는 인수는 상수일 수도 있고 아닐 수도 있는데 내부에서 읽기만 하므로 개념적으로 상수 속성을 주는 것이 옳다. int i=j; 연산 후 j의 값이 그대로 유지되어야 한다. 결론만 요약하자면 Class 클래스의 복사 생성자 원형은 Class(const Class &)여야 한다.
-> call by value 면 복사 생성자가 무한 호출
-> 포인터를 취하면, main에서 넘겨 줄때, &가 붙어야 하기 때문
디폴트 복사 생성자
클래스가 복사 생성자를 정의하지 않으면 컴파일러가 디폴트 복사 생성자를 만든다. 컴파일러가 만드는 디폴트 복사 생성자는 멤버끼리 1:1로 복사함으로써 원본과 완전히 같은 사본을 만들기만 할 뿐 깊은 복사를 하지는 않는다. 만약 디폴트 복사 생성자만으로 충분하다면(Position 클래스의 경우) 굳이 복사 생성자를 따로 정의할 필요는 없다. 이때 만들어지는 디폴트 복사 생성자는 다음과 같을 것이다.
Position(const Position &Other) {
x=Other.x;
y=Other.y;
ch=Other.ch;
}
대응되는 멤버끼리 그대로 대입하는데 전부 단순 타입이라 대입만 하면 잘 복사된다. 이런 디폴트 복사 생성자가 있기 때문에 별도의 조치가 없어도 Position There=Here가 잘 동작하는 것이다.
또한 Class A=B; 식의 선언을 하지 않거나 객체를 함수의 인수로 사용할 일이 전혀 없다는 것이 확실하다면 이때도 복사 생성자가 필요없다. 그러나 이런 가정은 무척 위험할 수 있다. 왜냐하면 클래스의 사용자는 클래스가 일반 타입과 동등하므로 int, double에서 가능한 일들은 클래스에 대해서도 모두 가능하다고 기대하며 실제로 그런 코드를 작성하기 때문이다. 이 기대에 부응하기 위해 클래스는 모든 면에서 기본 타입과 완전히 같아야 한다.
Person2 예제에서 복사 생성자를 정의함으로써 Person 클래스는 이미 생성된 객체로부터 새로운 객체를 선언할 수 있게 되었다. Person 클래스가 점점 기본 타입과 같아지고 있지만 이 클래스는 아직까지도 불완전하다. Person 클래스가 완전한 타입이 되려면 대입 연산자를 재정의해야 하는데 이 실습은 다음에 다시 해 보도록 하자.
26-2-다. 멤버 초기화 리스트
클래스가 타입이라면 int, double 같은 기본 타입도 클래스인가하는 질문을 할 수 있다. 이 질문에 대한 대답은 그렇다이다. C++은 일반적인 타입도 클래스와 동등하게 취급하며 클래스에 적용되는 문법이 일반 타입에 대해서도 적용된다. 그 예로 정수형 변수를 선언하면서 초기화하는 두 문장을 보자.
int a=3;
int a(3);
전자는 C에서 사용하던 전통적인 문법이고 후자는 C++의 객체를 초기화하는 문법이다. C컴파일러는 전자만 인정하지만 C++ 컴파일러는 일관성을 위해 후자도 인정한다. int a(3);이라는 선언문은 int 클래스 타입의 객체 a를 선언하되 생성자 int(int aa)를 호출하는 문장으로 해석할 수 있다. 실제로 이 문장이 int 클래스의 생성자를 호출하는가 아니면 C 방식대로 변수의 값만 초기화하는가는 컴파일러에 따라 다르겠지만 적어도 이론상으로는 생성자를 호출한다고 해도 전혀 억지가 아니다.
객체 초기화의 임무를 띤 생성자가 하는 주된 일은 멤버 변수의 값을 초기화하는 것이다. 그래서 생성자의 본체는 보통 전달받은 인수를 멤버 변수에 대입하는 대입문으로 구성된다. 멤버에 단순히 값을 대입하기만 하는 경우 본체에서 = 연산자를 쓰는 대신 초기화 리스트(Member Initialization List)라는 것을 사용할 수 있다. 초기화 리스트는 함수 선두와 본체 사이에 :을 찍고 멤버와 초기값의 대응 관계를 나열하는 것이다. Position 생성자를 초기화 리스트로 작성하면 다음과 같다.
Position(int ax, int ay, char ach) : x(ax),y(ay),ch(ach)
{
// 더 하고 싶은 일
}
초기화 리스트의 항목은 "멤버(인수)"의 형태를 띠며 멤버=인수 대입 동작을 한다. 단순한 대입만 가능하며 복잡한 계산을 한다거나 함수를 호출하는 것은 불가능하다. 위의 Position 생성자는 초기화 리스트의 지시대로 x는 ax로, y는 ay로, ch는 ach로 초기화한다. 초기화 리스트에서 모든 멤버에 값을 대입했으므로 본체는 아무 것도 할 일이 없어졌는데 물론 더 필요한 초기화가 있다면 본체에 추가 코드를 작성할 수 있다.
생성자 본체에서 값을 직접 대입하는 것과 초기화 리스트의 효과는 동일하므로 둘 중 편한 방법을 사용하면 된다. 그러나 다음 몇 가지 경우에는 본체에 대입문을 쓸 수 없으므로 반드시 초기화 리스트로 멤버를 초기화해야 한다. 주로 대입 연산을 쓸 수 없는 특수한 멤버의 경우이다.
상수 멤버 초기화
상수는 선언할 때 반드시 초기화해야 한다. const int year=365; 의 형식으로 상수를 선언하는데 여기서 =365를 빼 버리면 다시는 이 상수값을 정의할 수 없으므로 에러로 처리된다. 단, 클래스의 멤버일 때는 객체가 만들어질 때까지 초기화를 연기할 수 있으며 생성자의 초기화 리스트에서만 초기화 가능하다.
예 제 : InitConstMember
|
#include <Turboc.h>
class Some
{
public:
const int Value;
Some(int i) : Value(i) { }
void OutValue() { printf("%d\n",Value); }
};
void main()
{
Some S(5);
S.OutValue();
}
Some 클래스는 정수형의 상수 Value를 멤버로 가지고 있는데 상수에 대해서는 대입 연산자를 사용할 수 없다. 상수의 정의에 의해 다음 코드는 당연히 불법이다.
Some(int i) { Value=i; }
Value 멤버는 상수이므로 값을 변경할 수 없으며 대입 연산 자체가 인정되지 않는다. 그래서 초기화 리스트라는 특별한 문법이 필요하다. 초기화 리스트는 본체 이전의 특별한 영역이며 생성자에서만 이 문법이 적용된다. 상수는 원래 선언할 때 초기값을 주어야 하나 클래스 정의문에 다음과 같이 초기값을 주는 것은 불가능하다.
class Some
{
public:
const int Value=5;
클래스 선언문은 컴파일러에게 클래스가 어떤 모양을 하고 있다는 것을 알릴 뿐이지 실제 메모리를 할당하지는 않는다. 그러므로 Value 멤버는 아직 메모리에 실존하지 않으며 존재하지도 않는 대상의 값을 초기화할 수는 없다. 상수는 객체가 생성될 때 반드시 초기화되어야 하며 상수 멤버 초기화의 책임은 생성자에게 있다. 따라서 상수 멤버를 가지는 클래스의 모든 생성자들은 상수 멤버에 대한 초기화 리스트를 가져야 한다. 만약 이를 위반할 경우 에러로 처리된다.
이외에 상수 멤버값을 정적으로 선언하는 방법과 열거 멤버를 상수 대신 사용하는 방법이 있는데 다음에 상세히 알아보도록 하자. 여기서는 상수 멤버의 초기값을 주기 위해 초기화 리스트를 사용한다는 것만 알아 두자.
레퍼런스 멤버 초기화
레퍼런스는 변수에 대한 별명이며 선언할 때 반드시 누구에 대한 별명인지를 밝혀야 한다. 단, 예외적으로 함수의 형식 인수, 클래스의 멤버, extern 선언시는 대상체를 지정하지 않을 수 있는데 이때는 함수 호출시나 객체 생성시로 초기화가 연기된다. 레퍼런스 멤버를 가지는 클래스는 생성자에서 이 멤버를 초기화해야 하는데 다음 예제처럼 초기화 리스트를 사용한다.
예 제 : InitRefMember
|
#include <Turboc.h>
class Some
{
public:
int &ri;
Some(int &i) : ri(i) { }
void OutValue() { printf("%d\n",ri); }
};
void main()
{
int i=5;
Some S(i);
S.OutValue();
}
Some 클래스는 정수형 레퍼런스 변수 ri를 멤버로 가지고 있으며 생성자는 ri가 참조할 실제 변수를 인수로 전달받아 ri가 이 변수의 별명이 되도록 한다. 레퍼런스 멤버는 다음과 같이 대입 연산자로 초기화할 수 없다.
Some(int &i) { ri=i; }
왜냐하면 레퍼런스에 대한 대입 연산은 레퍼런스 그 자체의 대상체를 지정하는 것이 아니라 레퍼런스가 참조하고 있는 변수에 값을 대입하는 것으로 정의되어 있기 때문이다. 레퍼런스 멤버는 대입 연산자로 초기화할 수 없으며 반드시 초기화 리스트에서 대상체를 지정해야 한다. 레퍼런스 멤버 초기화 문법은 사실 앞의 상수 멤버 초기화 규칙과 동일한 것이라고 볼 수 있다. 왜냐하면 레퍼런스는 일종의 상수 포인터이기 때문이다.
레퍼런스는 생성 직후부터 별명으로 동작해야 하므로 선언할 때 짝이될 변수를 반드시 지정해야 한다. 그러나 레퍼런스를 초기식없이 선언할 수 있는 세 가지 예외적인 경우가 있는데(15-4-가 참조) 그 중 한가지가 바로 클래스의 멤버로 선언될 때이다. 이 경우 생성자는 레퍼런스의 짝을 찾아 주어야 할 막중한 임무를 띠며 만약 이 임무를 소홀히 할 경우 컴파일러로부터 섭섭하다는 에러 메시지를 받게 된다. 짝이 없는 레퍼런스는 절대로 존재할 수 없다.
포함된 객체 초기화
구조체끼리 중첩할 수 있듯이 클래스도 다른 클래스의 객체를 멤버로 가질 수 있다. 포함된 객체를 초기화할 때도 초기화 리스트를 사용한다.
예 제 : InitEmbeded
|
#include <Turboc.h>
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) { }
void OutValue() { printf("%d,%d\n",Pos.x, Pos.y); }
};
void main()
{
Some S(3,4);
S.OutValue();
}
Some 클래스가 Position 클래스의 객체 Pos를 포함하고 있는데 포함된 Pos 객체를 초기화하기 위해 생성자를 다음과 같이 작성할 수는 없다.
Some(int x, int y) { Pos(x,y); }
왜냐하면 생성자는 객체를 생성할 때만 호출할 수 있으며 외부에서 명시적으로 호출할 수 없기 때문이다. 그래서 멤버로 포함된 객체를 초기화할 때도 초기화 리스트를 사용해야 한다. 그렇다면 다음 코드는 어떨까?
Some(int x, int y) { Position Pos(x,y); }
생성자를 호출하는 문장처럼 보이지만 Pos는 생성자 함수내에서 임시적으로 만들어지는 지역 객체일 뿐이며 포함된 객체 Pos와는 이름만 같을 뿐 아무런 상관이 없다. 이 코드는 포함 객체 Pos를 초기화하는 것이 아니라 쓰지도 않는 지역 객체를 멤버와 같은 이름으로 하나 만들 뿐이며 이 객체는 생성자가 종료될 때 자동으로 파괴된다. 기본 타입의 멤버 변수도 일종의 포함된 객체로 볼 수 있으므로 x(ax), y(ay)식으로 초기화 리스트에서 초기화할 수 있다. 물론 기본 타입은 대입 연산에 의해 값을 대입할 수도 있으므로 생성자 본체에서 초기화하는 것도 가능하다.
만약 포함된 객체가 디폴트 생성자를 정의한다면 초기화 리스트에서 초기화하지 않아도 컴파일러가 디폴트 생성자를 호출하며 에러는 발생하지 않는다. 그러나 디폴트 생성자는 쓰레기를 치우는 정도 밖에 할 수 없으므로 원하는 초기화는 아닐 확률이 높다. 그렇지 않은 경우에는 반드시 초기화 리스트에서 적절한 생성자를 호출하여 포함된 객체를 초기화해야 한다.
이 외에 상속받은 멤버를 초기화할 때도 초기화 리스트를 사용하는데 이에 대해서는 상속을 배운 후에 다시 연구해 보도록 하자. 초기화 리스트를 반드시 사용해야 하는 경우는 상속받은 멤버까지 포함해서 총 4 가지 경우가 있다고 정리해 두자.
초기화 리스트를 반드시 사용해야 하는 것 !! 상수 멤버, 포함된 객체 멤버, 레퍼런스 멤버
초기화 리스트를 반드시 사용해야 하는 것 !! 상수 멤버, 포함된 객체 멤버, 레퍼런스 멤버
26-3.타입 변환
26-3-가.변환 생성자
일반 타입의 변수끼리 값을 대입할 때는 산술 변환 규칙에 따라 암시적으로 상호 변환된다. 물론 모든 타입들이 다 상호 변환되는 것은 아니며 호환되는 타입들끼리만 그렇다. 다음의 코드를 보자.
int i='C';
double d=12;
'C'는 문자형 상수지만 정수형 변수 i에 대입할 수 있으며 12는 정수형 상수지만 실수형 변수 d에 대입할 수 있다. 문자형이 정수형의 큰 타입으로 변환될 때는 암시적으로 상승 변환되며 반대의 경우는 하강 변환이 발생한다. 변수끼리 대입할 때나 함수의 인수로 전달될 때도 별다른 거부없이 암시적 변환이 적용된다. 물론 정수형 변수에 실수값을 대입하는 식의 하강 변환의 경우 약간의 정확도 손실이 발생할 수 있으므로 경고로 처리된다.
클래스의 객체들도 일반 타입과 마찬가지로 암시적 변환이 가능할 수 있는데 클래스가 일반 타입과 완전히 동등해지려면 타입을 변환할 수 있는 문법적 장치가 있어야 한다. 그 첫 번째 장치가 바로 변환 생성자(Conversion Constructor)이다. 변환 생성자는 기본 타입으로부터 객체를 만드는 생성자이며 인수를 하나만 취한다. 인수가 둘 이상이면 변환 생성자가 아니다. 다음 예제의 Time 생성자는 정수값으로부터 Time객체를 만든다.
예 제 : Convert1
|
#include <Turboc.h>
class Time
{
private:
int hour,min,sec;
public:
Time() { }
Time(int abssec) {
hour=abssec/3600;
min=(abssec/60)%60;
sec=abssec%60;
}
void OutTime() {
printf("현재 시간은 %d:%d:%d입니다.\n",hour,min,sec);
}
};
void main()
{
Time Now(3723);
Now.OutTime();
}
Time 클래스는 시간을 표현하며 시, 분, 초의 요소들을 멤버 변수로 가진다. 두 개의 생성자가 정의되어 있는데 디폴트 생성자와 변환 생성자이다. 시간이라는 값은 시, 분, 초의 3차원으로 표현하지만 자정 이후 경과한 시간을 절대초로 정의하고 절대초로 표현할 수도 있다. 가령 정오는 43200절대초이며 절대초 33956은 오전 9시 25분 56초가 된다. 절대초는 시간끼리의 계산에 유리한 표현법이며 실용적인 가치가 있다.
Time(int) 생성자는 정수형의 abssec 인수 하나만을 취하는데 절대초 abssec으로부터 시, 분, 초를 구해 객체를 초기화한다. 절대초로부터 시, 분, 초의 요소를 분리해내는 수식은 아주 간단한데 시간은 3600으로 나누면 되고 초는 60으로 나눈 나머지를 구하면 된다. 정수값 하나를 변환하여 객체를 생성하므로 이런 생성자를 변환 생성자라고 한다. main 함수의 첫 번째 문장 Time Now(3723);은 정수 상수 3723이라는 값으로부터 1:2:3초라는 Time형의 객체를 생성한다. 이 문장은 객체 선언 문법으로 변환 생성자를 직접적으로 호출하는 것이고 다음과 같이 간접적으로 호출할 수도 있다.
Time Now=3723;
int와 Time은 원래 호환되지 않지만 변환 생성자가 정의되어 있으면 컴파일러에 의해 자동 변환된다. 초기식의 우변이 정수이므로 컴파일러는 정수를 Time객체로 변환할 수 있는 변환 생성자를 찾아 호출한다. 변환 생성자가 정의되어 있으면 초기화할 때뿐만 아니라 언제든지 정수값을 Time 객체에 대입할 수도 있다. Now=1000; 대입문은 정수값 1000을 Time형 객체로 만들기 위해 Time(int) 생성자를 호출하여 임시 객체를 만들고 이 객체를 Now에 대입한다.
Time 클래스가 절대초라는 정수형의 개념을 지원하므로 정수를 암시적으로 변환하여 Time 객체를 만들 수 있는 기능은 무척 편리하다. 그러나 이런 기능이 예상치 못한 부작용의 원인이 될 수도 있는데 다음 코드를 보자.
void func(Time When)
{
When.OutTime();
}
void main()
{
Time Now(3723);
func(Now);
func(1234);
}
func 함수는 Time형의 객체를 인수로 전달받아 그 시간을 출력하는데 정수값을 전달해도 잘 동작한다. 실인수가 형식인수로 전달되는 과정은 일종의 대입 연산이며 이 과정에서 변환 생성자가 작동하여 정수값을 Time형의 임시 객체로 변환하기 때문이다. main에서 func(1234)를 호출했는데 1234가 절대초의 의미를 가지는 값이라면 아무런 문제가 없다.
그러나 만약 이것이 의도된 호출이 아니라 단순한 실수였다면 대단히 잡기 힘든 버그의 원인이 될 수 있다. Time형의 객체를 전달해야 하는데 정수값을 잘못 전달해도 컴파일러가 아무런 군말없이 변환을 해 버리니 디버깅을 해 보기 전에는 잘못을 알기 어렵다. 뿐만 아니라 func('S');나 func(123.456);같은 호출문조차도 에러로 처리되지 않는다. 문자형이나 실수형은 정수형으로 암시적 변환이 가능하고 이렇게 변환된 정수형은 다시 변환 생성자에 의해 Time형 객체로 변환 가능하기 때문이다.
변환 생성자는 편리하기도 하지만 클래스와 일반 타입간의 구분을 모호하게 만들어 버리는 맹점이 있다. 변환 생성자의 존재는 컴파일러에게 더 많은 암시적 변환 수단을 제공하여 엄격한 타입 체크를 방해하며 이는 버그의 원인이 되기에 충분하다. 이런 부작용이 우려되면 explicit 키워드를 변환 생성자앞에 붙인다.
class Time
{
private:
int hour,min,sec;
public:
explicit Time(int abssec)
....
explicit로 지정된 생성자는 암시적인 형 변환에 사용할 수 없도록 금지된다. 즉, 컴파일러가 임의적인 판단을 하지 못하도록 한다. 그러나 명시적인 형 변환이나 캐스트 연산자를 쓰는 것은 여전히 가능하다.
Time Now=3723; // 불가능
Time Now(3723); // 가능
Time Now=(Time)3723; // 가능
명시적인 생성자 호출이나 캐스트 연산자는 사용자가 변환하라는 의사를 분명히 밝힌 것이므로 explicit 키워드와는 상관없이 허용된다. 사용자가 책임을 지겠다고 변환을 지시했으므로 컴파일러는 이 지시를 거부할 필요도 명분도 없는 것이다. 하지만 대입이나 함수 호출에 의한 암시적인 변환은 컴파일 에러로 처리된다.
변환 생성자는 필요한만큼 정의할 수 있다. 만약 실수값으로부터 Time 객체를 생성하도록 하고 싶다면 실수 하나를 인수로 취하는 생성자를 정의하면 된다. 다음 생성자를 Time클래스에 추가해 보자.
Time(double d) {
hour=int(d)%24;
min=int((d-int(d))*100)%60;
sec=0;
}
실수를 어떻게 Time객체로 바꿀 것인가에 대한 명확한 변환 규칙이 필요한데 Time(double) 생성자의 경우 정수부를 시간으로, 소수부를 분으로 하고 초를 상수 0으로 고정시키는 규칙을 적용했다. 변환 규칙이 좀 억지스럽기는 하지만 이런 식으로 필요한 변환 생성자를 정의하면 된다. 이후 Time 객체는 Time A(12.34) 등의 선언문에 의해 실수값으로부터 변환 생성될 수 있다. 실제로 파스칼 언어는 실수 하나로 날짜와 시간을 표현하기도 한다.
변환 생성자는 반드시 인수를 하나만 취해야 하며 둘 이상을 취할 경우 변환 생성자가 아니다. 왜냐하면 변환이란 원칙적으로 일대일의 연산이며 Time A(1234); 선언문이나 A=1234; 대입문에서 보다시피 객체 초기화에 필요한 피연산자가 하나밖에 없다. 변환 생성자가 적용되는 초기화, 대입 연산은 이항 연산을 하는데 좌변은 객체 자신으로 정해져 있으므로 나머지 우변이 되는 변환 대상에 대해서만 인수를 전달받아야 한다. 단, 복사 생성자는 인수를 하나만 취하지만 동일 타입으로부터 사본을 생성하므로 변환 생성자라고는 할 수 없다.
26-3-나.변환 함수
변환 생성자를 정의하면 정수값으로부터 Time형 객체를 만들 수 있고 Time형 객체에 정수값을 대입할 수도 있다. 이것이 가능하다면 반대의 변환, 즉 Time형 객체로부터 정수값을 만들어내는 것도 가능할 것이다. 정수가 Time이 될 수 있다면 Time도 정수가 될 수 있어야 비로소 두 타입이 완전히 호환된다고 표현할 수 있다. 다음 코드가 제대로 동작해야 한다.
Time Now(18,25,12);
int i=Now;
printf("i=%d\n",i);
18:25:12초라는 시간이 절대초로 얼마인가를 계산한 후 정수값으로 출력해 보는 코드이다. 그러나 이 코드는 아직 동작하지 않는다. 왜냐하면 Time 클래스는 정수를 Time으로 바꾸는 변환 생성자만 제공할 뿐 자신을 정수로 바꾸는 방법은 제공하지 않기 때문이다. 객체를 일반 타입으로 역변환하려면 변환 함수(Conversion Function)를 정의해야 한다. 변환 함수의 형식은 다음과 같다. 다음에 상세하게 알아보겠지만 변환 함수는 캐스트 연산자에 대한 오버로딩의 한 예이다.
operator 변환타입()
{
본체
}
키워드 operator 다음에 변환하고자 하는 타입의 이름을 밝히고 본체에는 변환 방법을 작성한다. 변환 함수는 인수를 취하지 않으며 리턴 타입도 지정하지 않는다. 왜냐하면 연산 대상은 자기 자신으로 고정되어 있고 변환 결과는 지정한 타입임을 이미 알고 있기 때문이다. 객체 자신을 다른 타입으로 변환하는 동작을 하므로 작업거리와 결과가 이미 정해져있는 것이다. Convert1 예제의 Time 클래스에 시분초를 전달받는 생성자와 변환 함수를 추가해 보자.
예 제 : Convert2
|
#include <Turboc.h>
class Time
{
private:
int hour,min,sec;
public:
Time() { }
Time(int abssec) {
hour=abssec/3600;
min=(abssec/60)%60;
sec=abssec%60;
}
Time(int h, int m, int s) { hour=h; min=m; sec=s; }
operator int() {
return hour*3600+min*60+sec;
}
void OutTime() {
printf("현재 시간은 %d:%d:%d입니다.\n",hour,min,sec);
}
};
void main()
{
Time Now(18,25,12);
int i=Now;
printf("i=%d\n",i);
}
operator int() 변환 함수가 Time 클래스의 멤버 함수로 작성되어 있다. 변환 함수의 본체는 아주 단순한데 시간에 3600을 곱한 값, 분에 60을 곱한 값, 그리고 초를 모두 더하면 절대초를 쉽게 계산할 수 있으며 이렇게 구한 정수값을 리턴한다. 변환 함수의 원형에 리턴 타입이 없지만 어디까지나 생략된 것일 뿐이므로 본체에서는 return 문을 사용할 수 있다. 변환 함수에 의해 객체를 int로 변환할 수 있는 방법이 정의되었으므로 이제 Time형 객체는 정수형 변수에 대입될 수 있다.
main에서 Time형 객체 Now를 18:25:12초로 초기화하고 정수형 변수 i를 Now로 초기화했다. 이때 변환 함수가 호출되어 Now객체의 멤버값으로부터 절대초를 계산하여 리턴할 것이며 i는 그 결과값을 가진다. 출력되는 결과는 18:25:12초의 절대초인 66312가 된다. int와 호환된다는 것은 사실상 모든 수치형과 호환될 수 있다는 뜻이며 Time형 객체는 char, double, float, long 등의 타입과도 상호 변환 가능하다.
변환 함수와 변환 생성자는 하는 일이 비슷하기 때문에 닮은 점이 많다. 우선 변환 생성자와 마찬가지로 변환 함수도 필요한만큼 얼마든지 정의할 수 있다. Time 객체를 실수나 문자형으로도 변환하도록 하고 싶다면 operator double(), operator char() 함수를 더 정의하면 된다. 또한 변환 함수도 변환 생성자와 똑같은 이유로 다소 위험한 면이 있다.
void func(int i)
{
....
}
func 함수는 정수형 인수 하나를 받아 들이는데 이 함수에 대해 func(Now)로 호출할 수도 있다. 왜냐하면 변환 함수에 의해 Time형 객체가 정수로 변환될 수 있기 때문이다. 의도적인 호출이라면 물론 변환 함수의 서비스를 기분좋게 받겠지만 단순한 실수일 경우는 문제가 커진다. 다음 예를 자세히 살펴보자.
int Nox,Noy;
Time Now;
gotoxy(Nox,Now);
gotoxy의 두 번째 인수는 필시 Noy를 잘못 적은 것이겠지만 문법적으로 적법하며 컴파일러는 이 문장이 뭐가 문제인지 알 리가 없다. Now가 정수가 될 수 있으므로 gotoxy의 y좌표로 사용한다 하더라도 뭐 이상할게 없는 것이다. 심지어 ar[Now]=0; 같이 배열의 첨자에도 Time형 객체를 쓸 수 있으며 ptr+Now같이 포인터에 Time형 객체를 더하는 것도 허용된다. 변환 함수가 있으니 컴파일러는 이런 심히 수상해 보이는 코드에 대해서 경고 하나도 발생하지 않을 것이다.
게다가 변환 함수는 변환 생성자처럼 explicit로 암시적 변환을 금지하는 장치도 없어 주의 깊게 사용하는 수밖에 없다. 암시적 변환이 정 문제가 된다면 아예 변환 생성자나 변환 함수를 만들지 말고 TimeToInt, IntToTime 같은 명시적인 함수를 만들어 꼭 필요할 때만 사용하는 편이 더 안전하다.
예 제 : Convert3
|
#include <Turboc.h>
class Time
{
private:
int hour,min,sec;
public:
Time() { }
Time(int h, int m, int s) { hour=h; min=m; sec=s; }
void OutTime() {
printf("현재 시간은 %d:%d:%d입니다.\n",hour,min,sec);
}
int TimeToInt() {
return hour*3600+min*60+sec;
}
void IntToTime(int abssec) {
hour=abssec/3600;
min=(abssec/60)%60;
sec=abssec%60;
}
};
void main()
{
Time Now(18,25,12);
int i=Now.TimeToInt();
printf("i=%d\n",i);
Time Now2;
Now2.IntToTime(i);
Now2.OutTime();
}
이렇게 되면 변환이 필요할 때 사용자가 멤버 함수를 명시적으로 호출해야만 하며 컴파일러가 어떠한 변환 서비스도 하지 않으므로 좀 불편하기는 하지만 최소한 위험하지는 않다.
26-3-다.클래스간의 변환
앞 두 항에서 클래스와 기본 타입간의 변환에 대해 연구해 보았는데 이런 변환 장치들은 클래스에게 가급적 기본 타입과 동등한 자격을 주기 위해 마련된 것들이다. 이번에는 클래스끼리의 변환에 대해 알아보되 클래스가 타입이므로 사실 이는 클래스와 기본 타입간의 변환과 전혀 틀리지 않다. 간단한 예제 하나로 클래스간의 변환을 연구해 보자. 다음 예제는 섭씨 클래스와 화씨 클래스간을 변환한다.
예 제 : CelFah
|
#include <Turboc.h>
class Fahrenheit;
class Celsius
{
public:
double Tem;
Celsius() { }
Celsius(double aTem) : Tem(aTem) { }
operator Fahrenheit();
void OutTem() { printf("섭씨=%f\n",Tem); }
};
class Fahrenheit
{
public:
double Tem;
Fahrenheit() { }
Fahrenheit(double aTem) : Tem(aTem) { }
operator Celsius();
void OutTem() { printf("화씨=%f\n",Tem); }
};
Celsius::operator Fahrenheit()
{
Fahrenheit F;
F.Tem=Tem*1.8+32;
return F;
}
Fahrenheit::operator Celsius()
{
Celsius C;
C.Tem=(Tem-32)/1.8;
return C;
}
void main()
{
Celsius C(100);
Fahrenheit F=C;
C.OutTem();
F.OutTem();
printf("\n");
Fahrenheit F2=120;
Celsius C2=F2;
F2.OutTem();
C2.OutTem();
}
두 클래스가 서로를 상호 참조하므로 순서를 정할 수 없으며 나중에 선언되는 클래스에 대한 전방 선언이 필요하다. class Fahrenheit; 선언문은 이 명칭이 클래스의 일종이라는 것을 미리 알리며 변환 함수의 원형 선언을 위해 전방 선언이 먼저 되어 있어야 한다. 각 클래스의 멤버 함수들은 상대방 클래스의 모양을 정확하게 알아야 하므로 외부 정의만 가능하다.
섭씨, 화씨는 둘 다 온도를 나타내는 단위인데 섭씨는 물의 어는점을 0도, 끓는 점을 100도로 정하고 그 사이의 온도를 100등분한 것이며 화씨는 어는점, 끓는 점을 각각 32도, 212도로 정하고 그 사이의 온도를 180등분한 것이다. 두 온도는 다음과 같은 공식으로 변환 가능한데 더 자세한 내용은 네이버 지식인을 참조하기 바란다.
C=(F-32)/1.8
F=C*1.8+32
예제는 이 공식에 따라 Celsius 클래스에 화씨로 바꾸는 변환 함수를 정의하고 Fahrenheit 클래스에 섭씨로 바꾸는 변환 함수를 제공한다. 각 변환 함수는 상대편의 임시 객체를 만든 후 변환 공식대로 초기화하여 리턴한다. 지역변수를 리턴하는 코드가 상당히 어색해 보이겠지만 변환할 때만 잠시 사용하고 호출 객체에 대입되면 사라져도 상관없으므로 별 문제가 되지는 않는다.
두 클래스의 객체끼리는 암시적인 변환이 가능하여 상호 초기식에 사용할 수 있고 언제든지 상대편의 객체를 대입할 수 있다. 대입이 가능하므로 서로의 인수를 요구하는 함수로도 전달할 수 있다. 실행 결과는 다음과 같다.
섭씨=100.000000
화씨=212.000000
화씨=120.000000
섭씨=48.888889
두 클래스가 상호의 타입으로 변환하는 함수를 제공하는 대신 한쪽 클래스가 변환 생성자와 변환 함수를 동시에 제공하는 방식도 가능하다. Celcius::operator Farenheit() 변환 함수를 제거하고 Farenheit에 Celsius 타입으로부터 자신을 생성하는 변환 생성자를 정의해 보자.
class Fahrenheit
{
public:
double Tem;
Fahrenheit() { }
Fahrenheit(double aTem) : Tem(aTem) { }
Fahrenheit(Celsius C) {
Tem=C.Tem*1.8+32;
}
operator Celsius();
void OutTem() { printf("화씨=%f\n",Tem); }
};
이렇게 해도 결과는 동일하다. Celsius가 자신을 Farenheit로 변환하나 Farenheit가 Celsius로부터 자신을 생성하나 결국은 같은 변환인 것이다. 변환 생성자와 변환 함수는 상호 대체성이 있다고 할 수 있다. 어쨌든 양방향으로 두 개의 함수가 있으면 된다.
그렇다면 변환 함수만으로 필요한 변환을 다 할 수 있는데 변환 생성자는 굳이 왜 만들어 놓은 것일까? 그 이유는 기본 타입은 컴파일러에 내장되어 있어 마음대로 수정할 수 있는 대상이 아니며 변환 함수를 정의할 수 없기 때문이다. 예를 들어 섭씨, 화씨 모두 실수형으로 변환 가능한데 double이 클래스형으로 변환하지 못하므로 클래스가 double로부터 자신을 생성해야 하며 이때는 변환 생성자가 꼭 필요하다.
int, double같은 기본 타입뿐만 아니라 사용자 정의 타입도 때로는 수정할 수 없는 경우가 있다. 예를 들어 상용 클래스 라이브러리를 구입해서 사용하고 있다면 십중팔구 소스는 없으므로 이때도 라이브러리내의 클래스는 수정 대상이 아니다. 그러므로 쓰는 쪽에서 변환 생성자와 변환 함수를 모두 제공할 수밖에 없다.
이상으로 클래스와 기본 타입, 클래스간의 변환에 대해 연구해 봤는데 함수에 의해 변환이 이루어지므로 사실상 원하는 어떤 방식으로도 변환 가능하다. 그러나 타입간의 변환이란 서로 조금이라도 논리적인 호환성이 있을 때만 의미가 있다는 점을 명심하자. 섭씨와 화씨는 둘 다 온도라는 물리량을 표현한다는 점에서 공통적이고 범위만 다르기 때문에 간단한 수식으로 변환 가능하다. Time은 절대초라는 개념을 도입했기 때문에 정수형과 호환될 수 있었다.
그러나 Person과 Time처럼 논리적으로 전혀 호환되지 않는 타입끼리 변환 함수를 제공하는 것은 얼토당토 않은 일이다. C++은 임의의 타입끼리 원하는 방법으로 변환할 수 있는 문법을 제공하기는 하지만 절대로 이런 기능을 남용해서는 안된다. 꼭 필요할 때만 주의해서 사용하되 자신없으면 당분간 이런 기능은 아예 없다고 생각하는 것이 더 좋다. 변환 함수는 나름대로 흥미는 있지만 솔직히 실용성은 별로 없는 편이다. 잘못 사용하면 심각한 부작용이 나타날 수도 있고 예측하기 힘든 함정도 존재한다.
댓글 없음:
댓글 쓰기