반응형

대입연산자 오버로딩은 클래스 멤버로만 정의 가능하고 전역으로는 할 수 없다


정적으로도 만들 수 없다.




http://winapi.co.kr

28-3-다.대입 연산자

대입 연산자는 자신과 같은 타입의 다른 객체를 대입받을 때 사용하는 연산자이다. 객체 자체와 직접적인 연관이 있기 때문에 클래스의 멤버 함수로만 정의할 수 있으며 전역 함수로는 정의할 수 없다. 정적 함수로도 만들 수 없고 반드시 일반 멤버 함수로 만들어야 한다. 다음 예제는 앞장에서 만들었던 Person2예제에 디폴트 생성자를 추가하고 main 함수의 테스트 코드를 약간 수정한 것이다. Person 클래스는 생성자에서 동적으로 버퍼를 할당한다는 점에서 Time이나 Complex 클래스와는 다르며 이 버퍼를 주의깊게 다루어야 할 필요가 있다.

 

  : Person3

#include <Turboc.h>

 

class Person

{

private:

     char *Name;

     int Age;

 

public:

     Person() {

          Name=new char[1];

          Name[0]=NULL;

          Age=0;

     }

     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("을지문덕",25);

     Young=Boy;

     Young.OutPerson();

}

 

Person2 예제에서는 Young 객체를 선언할 때 Person Young=Boy; 형식으로 선언하면서 동시에 초기화를 했었다. 이때는 복사 생성자가 호출되는데 Person2예제에 복사 생성자가 작성되어 있으므로 이 코드는 이상없이 잘 동작한다. 그러나 일단 선언한 후 대입을 받게 되면 문제가 달라진다. 이 예제를 실행해 보면 프로그램이 종료될 때 다운되는 것을 확인할 수 있다.

선언과 동시에 다른 객체로 초기화하면 이때 복사 생성자가 호출되고 복사 생성자는 새로 생성되는 객체를 위해 별도의 버퍼를 준비하므로 두 객체가 버퍼를 따로 가져 아무런 문제가 없다. 그러나 실행중에 이미 사용중인 객체를 다른 객체로 대입할 때는 초기화 단계가 아니므로 복사 생성자는 호출되지 않는다. 다음 두 경우를 잘 구분하자.

대입은 ① 이미 생성된 객체에 적용된다. ② 실행중에 언제든지 여러 번 대입될 수 있다는 점에서 초기화와는 다르다. 실행중에 객체끼리 대입 연산을 하면 어떤 일이 벌어지는지 보자.

깊은 복사를 하는 대입

대입 연산자를 별도로 정의하지 않을 경우 컴파일러는 디폴트 대입 연산자를 만드는데 이 연산자는 디폴트 복사 생성자와 마찬가지로 단순한 멤버별 대입만 한다. 우변 객체의 모든 멤버 내용을 좌변 객체의 대응되는 멤버로 그대로 대입함으로써 얕은 복사만 하는 셈이다. 결국 Young의 Name 멤버는 Boy의 Name 멤버가 가리키는 버퍼의 주소를 그대로 가지게 될 것이다. 이때의 메모리 상황을 그림으로 그려 보자.

두 객체 모두 "강감찬"을 가리키고 있으며 main 함수가 종료될 때 각각의 파괴자가 호출되는데 먼저 파괴되는 객체가 Name 버퍼를 정리할 것이고 나중에 파괴되는 객체가 이 버퍼를 이중으로 정리하려고 하므로 무효해진 메모리를 해제하는 오류를 범하는 것이다. 결국 이 문제는 복사 생성자를 정의하지 않았을 때의 문제와 동일하며 생성과 동시에 초기화할 때처럼 대입을 받을 때도 깊은 복사를 하도록 해야 한다.

뿐만 아니라 생성할 때와는 달리 대입 연산은 실행중에 언제든지 여러 번 일어날 수 있기 때문에 객체가 사용중이던 메모리를 해제하지 않으면 다시는 이 메모리에 접근할 수 없는 문제도 있다. 위 그림에서 Young이 Boy를 대입받은 후 "을지문덕"은 더 이상 읽지도 쓰지도 못하며 해제할 방법조차 없다. 동적으로 할당한 메모리는 포인터가 진입점인데 이 진입점을 잃어버린 것이다. 이런 문제들을 해결하려면 = 연산자를 오버로딩하여 대입할 때도 깊은 복사를 하도록 해야 한다. Person 클래스에 다음 멤버 연산자 함수를 추가해 보자.

 

class Person

{

     ....

    Person &operator =(const Person &Other) {

        if (this != &Other) {

           delete [] Name;

           Name=new char[strlen(Other.Name)+1];

           strcpy(Name,Other.Name);

           Age=Other.Age;

        }

        return *this;

    }

};

 

복사 생성자의 코드와 유사한 코드가 반복되는데 대입되는 Other의 Name 길이+1만큼 버퍼를 새로 할당한 후 내용을 복사했다. Age는 단순한 정수형 변수이므로 그냥 대입하기만 하면 된다. 복사 생성자와 마찬가지 방법으로 깊은 복사를 하되 대입 동작은 실행중에 여러 번 그것도 임의의 순간에 발생할 수 있기 때문에 좀 더 신경써야 할 것들이 많다.

우선 Name 멤버를 할당하기 전에 이전에 사용하던 메모리를 먼저 해제해야 한다. 복사 생성의 경우 Name은 새로 만들어지는 중이므로 할당되어 있지 않지만 대입은 사용중인 객체에 대해 일어나는 연산이므로 Name이 이미 할당되어 있을 것이다. 다른 객체를 대입받는다는 것은 이전의 내용을 버린다는 뜻이므로 이미 할당된 메모리를 해제할 필요가 있는데 이 처리를 하지 않으면 대입할 때마다 이전에 사용하던 메모리가 누수될 것이다. 그래서 new 연산자로 Name을 할당하는 코드 앞에 delete [] Name이 필요하다. 이때 Name이 이미 할당되어 있는지는 점검할 필요가 없는데 디폴트 생성자가 1바이트를 할당하고 있으므로 Name은 항상 동적으로 할당되어 있기 때문이다.

그리고 대입 요청을 받았을 때 대입 대상이 자기 자신이 아닌지도 꼭 점검해야 하는데 A=A 같은 대입문도 일단은 가능해야 하기 때문이다. 이 문장은 자기가 자신의 값을 대입받는 사실상의 NULL문장이지만 고의든 실수든 아니면 코드의 일관성을 위해서건 틀린 문법은 아니므로 지원하는 것이 옳다. 자기 자신이 대입될 때는 아무 것도 하지 않고 자신을 리턴하기만 하면 된다. 만약 이 조건문을 빼 버리면 delete [] Name에 의해 자신의 버퍼를 먼저 정리해 버리고 정리된 버퍼의 내용을 다시 복사하려고 들기 때문에 객체의 내용이 제대로 유지되지 않을 것이다.

대입 후 리턴되는 값

대입 연산자의 리턴 타입이 Person &인 이유는 A=B=C식의 연쇄적 대입이 가능해야 하기 때문이다. 대입만이 목적이라면 void형으로 선언해도 상관없겠지만 기본 타입에서 가능한 모든 연산이 객체에서도 가능해야 하므로 가급적 똑같이 동작하도록 만들어야 한다. 대입 연산자가 대입된 결과값을 리턴하기 때문에 연쇄적인 대입이 가능하다.

이때 리턴되는 객체가 상수일 필요는 없는데 대입 후 리턴되는 객체를 바로 사용할 수도 있고 변경할 수도 있다. (Young=Boy).OutPerson(); 식으로 대입받은 좌변 객체에 대해 멤버 함수를 호출할 수 있다. 설사 이 멤버 함수가 객체의 상태를 변경하는 비상수 함수라도 말이다. 기본 타입도 대입 연산자에 의해 리턴되는 것은 좌변값인데 다음 테스트 코드를 통해 확인해 보자.

 

     int i=1,j=2;

     (i=j)=3;

     printf("%d,%d\n",i,j);

 

i=j 대입문에 의해 i에 2가 대입되고 i 자체가 리턴된다. 이때 리턴되는 레퍼런스는 좌변값이므로  바로 3을 대입할 수 있다. 출력되는 결과는 3,2가 된다. 실제로 이런 식은 잘 쓰이지도 않고 실용성도 없지만 어쨌든 클래스는 기본 타입과 같아야 하므로 기본 타입들이 하는 짓은 다 할 수 있어야 한다.

올바른 디폴트 생성자

Person3 예제는 디폴트 생성자를 정의하고 있으므로 Person Young; 선언문으로 일단 객체를 먼저 만들어 놓고 다른 객체의 값을 대입받아도 상관없다. 디폴트 생성자는 받아들이는 인수가 없으므로 멤버들을 NULL, 0, FALSE로 초기화하여 쓰레기를 치우는 것이 통상적인 임무이지만 동적 할당을 하는 클래스의 경우 포인터를 NULL로 초기화해서는 안된다. 왜 그런지 다음 테스트 코드를 실행해 보자.

 

     Person() { Name=NULL;Age=0; }

 

     Person Boy;

     Person Young=Boy;

 

디폴트 생성자가 쓰레기를 치우고 있으므로 인수없이 객체를 생성할 수 있다. 그러나 이렇게 만들어진 객체를 사용할 때 여기저기서 문제가 생긴다. 위 테스트 코드는 복사 생성자를 호출하는데 복사 생성자의 본체에서 strlen 함수로 Other.Name의 길이를 구하고 있다. 0번지는 허가되지 않은 영역이므로 이 번지를 읽기만 해도 당장 다운되어 버린다. 복사 생성자가 쓰레기만 치운 객체를 전달받아도 죽지 않으려면 예외 처리 코드가 더 작성되어야 한다.

 

     Person(const Person &Other) {

          if (Other.Name == NULL) {

              Name=NULL;

          } else {

              Name=new char[strlen(Other.Name)+1];

              strcpy(Name,Other.Name);

          }

          Age=Other.Age;

     }

 

초기식의 객체가 NULL 포인터를 가리키면 새로 선언되는 객체도 같이 NULL포인터를 가지도록 해야 한다. 복사 생성자뿐만 아니라 대입 연산자, Name을 참조하는 모든 멤버 함수에서 Name이 NULL인 경우를 일일이 예외 처리해야 하는 것이다. 이렇게 하는 것이 귀찮고 비효율적이기 때문에 디폴트 생성자가 포인터를 초기화할 때는 비록 1바이트라도 할당하여 Name이 NULL이 되지 않도록 하는 것이 좋다. 비록 1바이트에 빈 문자열밖에 들어 있지 않지만 이 메모리도 동적으로 할당한 것이므로 읽을 수 있다.

Person3의 디폴트 생성자가 할당하는 1바이트는 자리만 지키는 플레이스 홀더(PlaceHolder) 역할을 한다. 아무 짝에도 쓸모없는 것 같지만 Name이 반드시 동적 할당된 메모리임을 보장하여 이 버퍼를 참조하는 모든 코드를 정규화시키는 효과가 있다. 모든 멤버 함수는 Name의 길이가 얼마이든지 무조건 할당되어 있다는 가정하에 Name을 안심하고 액세스할 수 있다.

동적 할당 클래스의 조건

이 예제에서 보다시피 초기화와 대입은 여러 모로 다르다는 것을 알 수 있다. 초기화는 객체를 위한 메모리를 할당할 때 이 공간을 어떻게 채울 것인가를 지정하며 일회적인데 비해 대입은 실행중에 같은 타입인 다른 객체의 사본을 작성하며 회수에 제한이 없다. 대입이 초기화보다는 훨씬 더 복잡하고 비용도 많이 든다. 그래서 컴파일러는 복사 생성자와 대입 연산자를 구분해서 호출하며 따라서 우리는 둘 다 만들어야 한다. class A=B; 선언문을 디폴트 생성자로 A를 먼저 만든 후 B를 대입하는 것으로 처리할 경우 속도가 훨씬 더 늦어질 것이다. 실제로 구형 컴파일러는 이런 식으로 초기화를 구현했었다.

Time이나 Complex 클래스는 복사 생성자가 없어도 선언할 때 다른 객체로 초기화할 수 있으며 대입 연산자를 굳이 정의하지 않아도 객체끼리 안심하고 대입할 수 있다. 왜냐하면 값만을 가지는 클래스는 컴파일러가 만들어 주는 디폴트 복사 생성자, 디폴트 대입 연산자만으로도 충분히 잘 동작하기 때문이다. 이에 비해 Person 클래스는 동적으로 할당하는 메모리가 있기 때문에 여러 모로 관리해야 할 것들이 많은데 최소한 다음과 같은 함수들이 있어야 한다.

 

함수

설명

생성자

생성될  메모리를 할당한다.

파괴자

사용하던 메모리를 반납한다.

복사 생성자

초기화될  별도의 메모리를 할당한다.

대입 연산자

사용하던 메모리를 해제하고 대입받는 객체에 맞게 다시 할당한다.

 

이 중 하나라도 빠지거나 생략되면 Person 클래스는 제대로 동작하지 않는다. 생성자는 초기화라는 중요한 임무를 가지므로 꼭 동적 할당을 하지 않더라도 대부분의 클래스에 필수적이다. 나머지 셋은 생성자에서 동적 할당이나 그와 유사한 효과의 동작을 할 때 꼭 필요한데 셋 중 하나가 필요하다면 나머지 둘도 마찬가지로 필요하다. 그래서 이 셋은 같이 뭉쳐서 다니는 특징이 있으며 흔히 삼총사라고 부른다.

Person3 예제의 Person 클래스는 비로소 완벽해졌으며 선언과 동시에 초기화, 실행중 대입 등이 가능해져 기본 타입과 동등한 자격을 가지게 되었다. 그러나 상속을 하지 않을 경우에만 완벽하며 상속할 경우 파괴자가 가상 함수여야 한다는 조건이 하나 더 추가된다. 이 예에서 동적으로 할당되는 메모리란 클래스 동작에 꼭 필요한 어떤 자원의 비유에 해당한다. 예를 들어 하드웨어 장치를 열어야 하거나 네트워크 접속, DB 연결, 권한 획득 등이 필요한 클래스는 모두 비슷한 법칙이 적용된다. 아무튼 멤버를 그대로 복사해서는 똑같은 객체를 만들 수 없는 모든 클래스에는 이런 함수들이 필요하다.

복합 대입 연산자

이번에는 대입 연산자와 유사한 복합 대입 연산자를 오버로딩해 보자. 복합 대입 연산자는 대입과 비슷한 동작을 하기는 하지만 아예 다른 연산자이므로 필요할 경우 따로 정의해야 한다. 예를 들어 Time 클래스에 operator + 연산자를 오버로딩했다고 해서 operator += 까지 같이 정의되는 것은 아니다. 다음은 += 복합 대입 연산자의 오버로딩 예이다.

 

  : OpPlusEqual

#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);

     }

     Time &operator +=(int s) {

          sec += s;

          min += sec/60;

          sec %= 60;

          hour += min/60;

          min %= 60;

          return *this;

     }

};

 

void main()

{

     Time A(1,1,1);

 

     A+=62;

     A.OutTime();

}

 

+ 연산자와 다른 점은 호출한 객체를 직접 변경시키기 때문에 const가 아니라는 점, 그리고 자기 자신이 피연산자이므로 임시 객체를 필요로 하지 않는다는 점 정도이다. A+=62 연산문에 의해 A가 가진 시간에 62초를 더한 값이 A에 다시 대입된다. 사용자는 + 연산이 가능하면 +=연산도 가능하다고 기대하므로 가급적이면 두 연산자를 같이 제공하는 것이 좋다. 이 경우 +=을 먼저 정의해 놓고 + 연산자는 이 함수를 호출하는 것이 효율적이다.

 

Time operator +(int s) {

     Time R=*this;

     R+=s;

     return R;

}

 

+=에 정수를 더하는 연산이 먼저 정의되어 있으므로 +는 임시 객체에 += 연산한 결과를 값으로 리턴하기만 하면 된다. 뿐만 아니라 덧셈의 규칙이 변경되더라도 +=의 코드만 수정하면 되므로 코드를 유지하기도 훨씬 더 쉽다.

복사 생성 및 대입 금지

클래스는 일종의 타입이므로 기본 타입과 완전히 동일해질 수 있는 모든 문법이 제공된다. 선언, 초기화, 복사, 대입, 연산 등등 int가 할 수 있는 모든 동작을 다 할 수 있다. 그러나 경우에 따라서는 이런 것이 어울리지 않거나 그래서는 안되는 클래스들도 있다. 예를 들자면 하드웨어를 직접적으로 제어하거나 유일한 자원을 관리하는 객체를 들 수 있는데 하나만 가지고도 충분히 원하는 동작을 모두 할 수 있으므로 굳이 둘을 만들 필요가 없다.

이런 예는 멀리서 찾을 것도 없이 표준 입출력 스트림 객체인 cin, cout을 보면 된다. 이 객체 한쌍으로 화면에 원하는 모든 출력을 할 수 있고 키보드로 입력을 받을 수 있는데 cin, cout이 두 개씩 있을 필요가 없지 않은가? 어떤 경우에는 동일한 타입의 객체가 두 개 있을 경우 혼선이 빚어지기도 하고 서로 간섭하여 오동작하거나 데드락에 걸리는 부작용도 있다. 이런 클래스들은 허가되지 않는 연산을 적절히 막아야 하는데 금지할 필요가 있는 대표적인 연산이 복사 생성과 대입이다.

복사 생성과 대입을 못하는 클래스를 만드는 방법은 생각보다 쉽다. 복사 생성자와 대입 연산자를 선언하되 둘 다 private 영역에 두는 것이다. 아예 정의하지 않으면 컴파일러가 디폴트를 만드므로 반드시 private영역에 직접 선언해야 한다. 어차피 호출되지 않을 함수들이므로 본체의 내용은 작성하지 않아도 상관없다. Person 클래스는 복사, 대입이 모두 가능한 경우이긴 하지만 금지해야 한다고 가정하고 위 예제를 대상으로 이 동작들을 금지시켜 보자.

 

class Person

{

private:

     char *Name;

     int Age;

     Person(const Person &Other);

     Person &operator =(const Person &Other);

     ....

 

이렇게 해 놓으면 Person Young=Boy; 같은 선언문이나 Girl=Boy; 같은 대입문이 실행될 때 컴파일러가 복사 생성자나 대입 연산자를 호출하려고 할 것이다. 객체를 선언하는 곳은 객체의 외부이므로 private 멤버를 호출할 수 없으며 컴파일 중에 이 동작이 허가되지 않는다는 것을 알 수 있다. 실행중에 문제를 일으키는 것보다 컴파일할 때 이 동작은 금지되었음을 확실히 알리는 것이 바람직하다. 좀 더 적극적으로 에러 내용을 상세하게 알리고 싶을 때는 이 둘을 public 영역에 두되 assert문을 작성해 놓는 방법을 쓸 수 있다.

두 함수를 private 영역에 둘 때 본체 내용은 아예 작성하지 않는 것이 좋다. 왜냐하면 외부에서 이 함수를 호출하는 것은 컴파일러가 컴파일 중에 막아 주지만 클래스 내부의 멤버 함수나 프렌드 함수에서는 여전히 이 함수를 호출할 수 있기 때문이다. 함수를 선언만 해 놓고 본체를 정의하지 않더라도 이 함수가 호출되기 전에는 링커가 본체를 찾지 않으므로 아무 이상이 없다. 만약 정의되지도 않는 함수를 호출하려고 하면 컴파일은 무사히 되지만 링크할 때 에러로 처리되므로 이 동작이 불가능하다는 것을 알 수 있다. 외부에서 불가능한 동작을 시도하면 컴파일러가 막아주고 내부에서 엉뚱한 짓을 하려면 링커가 막아준다. C++의 객체는 이런 식으로 실수든 고의든 허가되지 않는 위험한 연산을 스스로 방어하도록 작성되어야 한다.

복사 생성자, 대입 연산자 작성 규칙은 나름대로 복잡해서 이해는 되더라도 실무에서 직접 작성하기는 쉽지가 않다. 개념적인 이해는 꼭 해 두고 실제 코드를 작성할 때는 Person3 예제에서 코드를 복사한 후 원하는 부분만 수정하는 것이 편리하다. Person3 예제의 복사 생성자, 대입 연산자는 모든 상황에 대해 잘 작동하도록 만든 모범 답안이다.

 

반응형

+ Recent posts