본문 바로가기
Basic/C/C++

[C++ 언어] 제 11 강 : 연산자 오버로딩(2)

by boxbop 2013. 3. 26.
반응형



 연산자 오버로딩 두번째 시간입니다.

이번에는 연산자를 오버로딩하되, 전역 함수에 의한 오버로딩이 반드시 필요한 상황 하나를 소개하겠습니다.

차근차근 진행할거니까 잘 따라오세요~


[잘못된 형태의 연산자 오버로딩]


int main(void)

{

int a=10, b=20;

int c=a+b;

...(생략)

}


 위 예제처럼 a와 b를 더하 결과 값이 단순히 c에 저장되는 것이지 변수 a 혹은 b 의 값이 변경되는 것은 아닙니다.

바로 다음 예제를 살펴보겠습니다.


#include <iostream>

using std::endl;

using std::cout;


class Point {

private:

int x, y;

public:

Point(int _x=0, int _y=0):x(_x), y(_y){}

void ShowPosition();

Point operator+(int val);

};

void Point::ShowPosition() {

cout<<x<<" "<<y<<endl;

}

Point Point::operator+(int val) {

Point temp(x+val, y+val);

return temp;

}


int main(void)

{

Point p1(1,2);

Point p2=p1+3;

p2.ShowPosition();


return 0;

}


//결과값 : 4 5


 두번째 볼드처리된 문장을 보시면 p1과 3을 덧셈 연산하고 있습니다. 다음과 동일한 문장이 될겁니다.

-> Point p2 = p1.operator+(3);


 첫번째 볼드처리된 문장을 봅시다. 여기서 정의되어있는 operator+ 함수를 보면 x, y 인자에 전달된 피연산자 val을 더해서 새로운 객체를 생성 및 리턴하고 있습니다. 말씀드리고자하는 중요한 사실은 일반적인 + 연산자와 operator+ 함수의 기능이 일치한다는 것이죠. 본래의 + 연산자의 기능을 크게 벗어나지 않는 것이 좋은 연산자 오버로딩입니다.


[교환 법칙의 적용]


 이미 Point 클래스의 객체가 정의 되어있다고 가정한다면 Point 클래스의 객체를 이용해서 10 + p 와 같은 형태의 연산도 가능할까요? 방금 실행했던 예제처럼 오버로딩이 되어있다고 했을때라면 결론은 "불가능하다" 입니다. 10 + p는 다음과 동일한 문장이라고 할 수 있습니다.

                                    -> 10.operator+(p);

뭔가 이상하죠? 결국 전역 함수에 의한 연산자 오버로딩이 필요하다는 결론이 나옵니다. 즉, 우리 입장에서는 p + 10 과 10 + p 가 서로 다른 문장으로 취급된다는걸 알고 있지만 +연산자는 교환법칙이 성립되므로 같게 만들어 주어야 합니다.


그러면 다음예제를 통해서 10 + p 가 "p + 10" 이 되도록 바꿔주기만 하면 됩니다.


Point operator+(int val, Point& p)

{

return p+val;

}


위와 같이 정의하면 Point p3 = 3 + p2 는 내부적으로 어떻게 해석이 될까요?

Point p3 = 3 + p2   ---->   Point p3 = operator+(3,p2)   ---->   Point p3 = "p2+3;의 연산 결과"

이해가 가시나요~? 어렵지 않습니다.


*참고 : + 연산자가 두가지 형태로 오버로딩된 부분이 이상할 수 있습니다. 하지만 오버로딩된 함수를 호출하는 피연산자의 형태가 각각 다르기 때문에 전혀 문제되지 않습니다. 물론 피연산자의 형태가 둘다 동일하다면 문제가 되겠죠?


[임시 객체의 생성]


 임시 객체란 임시적으로 생성되었다가 소멸되는 객체입니다. 다음 예제는 임시 객체 생성의 예를 보여줍니다.


#include <iostream>

using std::endl;

using std::cout;


class AAA{

char name[20];

public:

AAA(char* _name) {

strcpy(name, _name);

cout<<name<<" 객체 생성 " <<endl;

}

~AAA() {

cout<<name<<" 객체 소멸 "<<endl;

}

};


int main(void)

{

AAA aaa("aaa Obj");

cout<<" ---임시 객체 생성 전---"<<endl;

AAA(" Temp Obj");

cout<<"---임시 객체 생성 후---"<<endl;

return 0;

}


볼드 처리된 부분을 보면 AAA클래스의 생성자를 호출하면서 임시 객체를 생성하고 있습니다. 그런데 이러한 임시 객체의 특징은 "이름이 없다" 는 것입니다. 따라서 생성한 이후에 그 줄에서 사용하지 않으면 바로 소멸되어 버리는 특징을 가집니다.


실행결과 :

aaa Obj 객체 생성

---임시 객체 생성 전---

Temp Obj 객체 생성

Temp Obj 객체 소멸

---임시 객체 생성 후---

aaa Obj 객체 소멸


 실행 결과는 임시 객체가 생성되었다가, 그 다음줄로 넘어가면서 바로 소멸된다는 것을 보여주고 있습니다.

그렇다면 이러한 임시 객체를 언제 사용할까요? 임시 객체의 효율적인 사용 예를 하나 들어보겠습니다.


Point Point::operator+(int val)

{

Point temp(x+val, y+val);

return temp;

}


 위 함수는 새로운 객체를 만들어서 리턴하는 함수입니다. 그래서 temp라는 이름의 객체를 생성하고, 그 다음줄에서 그 객체를 리턴해주고 있지요. 임시 객체를 사용하면 다음과 같이 간결하게 표현할 수 있습니다.


Point Point::operator+(int val) {

return Point(x+val, y+val);

}


 위 함수는 임시 객체를 생성하자마자 바로 리턴해주고 있습니다. 임시 객체는 다음 줄로 넘어가면 소멸된다고 했지만 위의 경우에는 생성하자마자 리턴해 주므로 문제될 것이 없겠습니다.


 한가지 더 중요한 사실은 위와 같은 형태로 임시 객체를 사용할 경우, 컴파일러에 따라서 프로그램 최적화가 진행된다는 사실입니다.


[cout, cin 그리고 endl의 구현 이해]


 지금 소개할 내용은 전혀 새로운 내용의 주제가 아닙니다. 이미 우리가 사용하고있고 알고 있는 것들을 위주로 살펴보도록 하겠습니다. 우선 다음 예제를 살펴보겠습니다.


#include <stdio.h>


namespace mystd

{

char* endl = " \n"; //\는 역슬래쉬인거 아시죠?


class ostream

{

public:

void operator<<(char * str) {

printf("%s", str);

}

void operator<<(int i) {

printf("%d", i);

}

void operator<<(double i) {

printf("%e", i);

}

};

ostream cout; //ostream 객체 생성

}


using mystd::cout;

using mystd::endl;


int main(void)

{

cout<<"hello\n";

cout<<1.23;

cout<<endl;

cout<<1;

cout<<endl;

return 0;

}


 여기서 사용한 cout와 << 연산자는 우리가 직접 정의한 것 입니다. 출력을 위한 << 연산자는 오버로딩된 함수의 형태를 지니고 있는거 아시겠죠? mystd라는 이름 공간은 표준 이름공간 std를 흉내 낸 것 입니다. mystd 안에는 ostream 이라는 출력을 위한 클래스도 정의되어 있습니다. ostream 클래스의 객체도 cout 라는 이름으로 생성되어 있습니다.


 우리는 위의 예제를 통해서 cout 와 << 연산자의 정체를 파악할 수 있게 되었습니다. 그러나 여기에는 한가지 문제가 있습니다. 다음 문장을 보세요~


 cout<<"Hello"<<100<<endl;


위 문장은 아래 문장과 동일합니다.


 ((cout<<"hello")<<100)<<endl;


그렇다면 실행 과정은 다음과 같이 진행될 겁니다.


  ((cout<<"hello")<<100)<<endl;  ====> (cout<<100)<<endl; ====> cout<<endl;

  ->연산후 cout 리턴                                ->연산후 cout 리턴


즉, 연산자를 오버로딩하고 있는 operator<< 함수는 cout 객체를 반환해야 된다는 중요한 결론이 나옵니다. 따라서 다음과 같이

이름공간을 수정해보겠습니다.


namespace mystd

{

char* endl = " \n"; //\는 역슬래쉬인거 아시죠?


class ostream

{

public:

ostream& operator<<(char * str) {   //참조에 의한 리턴

printf("%s", str);

return *this;

}

ostream& operator<<(int i) {

printf("%d", i);

return *this;

}

ostream& operator<<(double i) {

printf("%e", i);

return *this;

}

};

ostream cout; //ostream 객체 생성

}


operator<< 함수의 정의를 보면 마지막에 자기 자신을 리턴하고 있음을 볼 수 있습니다. 또한 리턴 타입이 ostream 이 아닌 ostream& 입니다. 참조에 의한 리턴을 하고 있는 것이죠. 사실 이 상황에서는 어떠한 형태로 리턴을 하건 실행 결과에는 차이가 없습니다. 하지만 리턴하는 대상이 함수 내에서 생성된 객체가 아니므로, 참조에 의한 리턴이 보다 효율적이기 때문이죠.


 [<<, >> 연산자의 오버로딩]


다음 문장이 가능하도록 오버로딩을 하고 싶을땐 어떻게 해야될까요?


int main(void)

{

Point p(1, 2);

cout<<p;

}


 결과값은 [1, 2]가 출력되기를 기대하고 있습니다. "cout<<p"의 형태로 객체 p의 맴버 변수가 출력되기를 원한다면, 역시 연산자 오버로딩 밖에 방법이 없습니다. 만약 맴버 함수에 의한 방법으로 해결을 하고자 한다면 다음 문장이 성립되어야 합니다.

 cout<<p   ->  cout.operator<<(p)

하지만 cout 객체의 맴버함수 operator<<는 Point 객체 p를 인자로 받을 수 있을까요? Point 객체는 우리가 임의대로 정의한 클래스 입니다. 때문에 객체 p를 인자로 받는 다는 것은 불가능하지요.


 만약에 맴버 함수에 의한 오버로딩이 가능하려면 sotream 클래스에 다음과 같은 형태의 맴버 함수를 정의해 넣어야 합니다만 불가능한 일입니다. 왜냐하면 c++표준에서 제공하는 클래스를 임의로 변경할 수 없기 때문입니다.


 ostream& operator<< (const Point& p)


 따라서 전역 함수에 의한 오버로딩 방식을 사용해야합니다. 다음과 같은 형태로 말이죠


 ostream& operator<< (ostream& os, const Point& p)


전체적인 예를 나타내보겠습니다.


#include <iostream>

using std::endl;

using std::cout;


using std::ostream;


class Point {

private:

int x, y;

public:

Point (int _x = 0, int _y = 0):x(_x),y(_y){}

friend ostream& operator<<(ostream& os, const Point& p);

};


ostream& operator<<(ostream& os, const Point& p) //cout을 인자로 전달 받는다. 따라서 ostream 객체를 인자로 받는다고

{                                                                          선언되어 있는데, ostream은 이름공간 std 안에 선언되어 있다.

os <<" [ " <<p.x<<","<<p.y<<"]"<<endl;

return os;

}


int main(void)

{

Point p(1,3);

cout<<p; //operator<< (cout, p); 와 동일한 문장입니다.

return 0;

}

 

[배열의 인덱스 연산자 오버로딩]

 

 이번에 오버로딩해 볼 것은 배열의 요소에 접근할 때 사용되는 []연사자 입니다.(array[3]) 다들아시죠~?

다음예제를 기준으로 설명하겠습니다. 일단 기본이 되는 예제를 살펴보도록 하겠습니다.

 

#include <iostream>

using std::endl;

using std::cout;

 

const int SIZE=3;

 

class Arr {

private:

int arr[SIZE];

int idx;

public:

Arr() : idx(0) {}

int GetElem(int i);

void SetElem(int i, int elem);

void AddElem(int elem);

void ShowAllData();

};

int Arr::GetElem(int i) {

return arr[i];

}

void Arr::SetElem(int i, int elem) {

if(idx <= i) {

cout<<"존재하지 않는 index 입니다.!"<<endl;

return;

}

arr[i]=elem;

}

void Arr::AddElem(int elem) {

if(idx >= SIZE) {

cout<<"index 용량 초과!"<<endl;

return;

}

arr[idx++]=elem;

}

void Arr::ShowAllData() {

for(int i=0; i<idx; i++)

cout<<"arr["<<i<<"]="<<arr[i]<<endl;

}

 

int main(void)

{

Arr arr;

arr.AddElem(1);

arr.AddElem(2);

arr.AddElem(3);

arr.ShowAllData();

 

arr.SetElem(0, 11);

arr.SetElem(1, 22);

arr.SetElem(2, 33);

 

cout<<arr.GetElem(0)<<endl;

cout<<arr.GetElem(1)<<endl;

cout<<arr.GetElem(2)<<endl;

 

return 0;

}

 

 위의 예제는 따로 설명 안드려도되겠죠? 충분히 이해하시리라 믿습니다.

 

자 여기서 우리가 정의한 클래스를 이제 인덱스 연산자([])를 통한 참조가 가능하도록 만들어야합니다.

아래와 같은 식으로 말이죠.

 

int main(void)

{

Arr arr;

arr.AddElem(1);

arr.AddElem(2);

arr.AddElem(3);

arr.ShowAllData();

 

arr[0] = 1;

arr[1] = 2;

arr[2] = 3;

 

cout<<arr[0]<<endl;

cout<<arr[1]<<endl;

cout<<arr[2]<<endl;

return 0;

}

 

 여기서는 인덱스 연산자를 이용해서 접근을 시도함을 알 수 있습니다.

arr[i] 는 arr.operator[](i) 와 같은 문장으로 해석이 될 겁니다. 이러한 인덱스 연산자에

대입연산자를 사용하여 사용이 되고 있습니다. 여기서 중요한 것! 대입 연산자를 사용하기 위함은

즉, 참조에 의한 리턴이 이루어져야 가능한 일입니다. 때문에 다음과 같은 맴버 함수가 필요하겠죠?

int& Arr::operator[] (int i) {

return arr[i];

}

 

 자 이번에는 조금 더 생각해볼 문제입니다.

만약에 Point 클래스가 있고 p1과 p2 객체를 만들었다고 해봅시다.

main함수에서 p1= p2; 라는 문장이 성립이 될까요? 예 성립이 됩니다. 물론 클래스 정의부분에서는

오버로딩하는 함수가 따로 없더라도 말입니다.

 

 왜냐하면 대입연산자를 오버로딩하는 함수도 프로그래머가 정의해 주지 않으면 디폴트 대입 연산자라는 이름으로 제공이

됩니다. 즉, 맴버 변수 대 맴버 변수의 복사를 이루게되죠. 다음과 같은 맴버 함수가 정의되어 있습니다.

 

Point& Point::operator=(const Point& p)

{

x = p.x;

y = p.y;

return *this;

}

 

 리턴 타입이 Point& 형이고, 자기 자신을 리턴하는 이유는 p1 = p2 = p3 와 같은 문장을 허용하기 위함이죠.

그러나 디폴트 대입 연산자에는 문제점이 하나 있습니다. 일단 다음 예제를 살펴보도록 하겠습니다.

 

#include <iostream>

using std::endl;

using std::cout;

using std::ostream;

 

class Person {

private:

char* name;

public:

Person(char* _name);

Person(const Person& p);

~Person();

friend ostream& operator<<(ostream& os, const Person& p);

};

 

Person::Person(char* _name) {

name = new char[strlen(_name)+1];

strcpy(name,_name);

}

Person::Person(const Person& p) {

name = new char[strlen(p.name)+1];

strcpy(name,p.name);

}

Person::~Person() {

delete[] name;

}

 

ostream& operator<<(ostream& os, const Person& p)

{

os<<p.name;

return os;

}

 

int main()

{

Person p1("ABC");

Person p2("DEF");

 

cout<<p1<<endl;

cout<<p2<<endl;

 

p1 = p2;

 

cout<<p1<<endl;

return 0;

}

 

 자 과연 어느 부분에서 문제가 생기는 걸까요? 네 디폴트 대입 연산자에 있습니다. 물론 보이지는 않습니다.

아래의 문장이 Person 클래스에 자동으로 삽입되는 디폴트 대입 연산자의 형태입니다.

 

Person& Person::operator=(const Person& p)

{

name=p.name;

return *this;

}

 

 이러한 대입 연산자는 맴버 대 맴버를 복사하는 형태로 정의되어 있습니다. 하지만 문제가 생깁니다.

p1 = p2 라는 문장을 생각해봅시다.

 문제는 얕은 복사에 있습니다. 때문에 p1의 맴버 변수 name이 단순하게 p2 의 맴버변수 name이 가리키는 대상을 가리키게 될 것 입니다. 이는 포인터 부분에서 설명한 적 있는데요. 이유는 단순히 포인터 값만 복사했기 때문입니다. 그렇다면!! 객체가 소멸되는 시점에 각각의 객체의 소멸자가 호출될 겁니다. 그 그결과 p1의 맴버 변수에 저장되어있던 값은 이 문자열을 가리키는 대상이 없어졌기 때문에 접근이 불가능하게 됩니다. 동적으로 할당된 메모리 공간이기 때문에 메모리 유출이 발생을 하겠죠? 그리고 p2의 맴버 변수인 name은 p1의 소멸자, p2의 소멸자가 각각 호출되기때문에 두번 삭제되는 문제점을 가지고 있습니다.

 때문에 우리가 직접 정의해주는 대입 연산자에서는 적절한 메모리 해제와 깊은 복사가 이루어지도록 정의해주어야 합니다. 다음과 같이 말이죠

 

Person& Person::operator=(const Person& p)

{

delete []name; //메모리 유출방지. 즉, p1의 name이 할당한 메모리공간을 해제시켜줍니다.

name = new char[strlen(p.name)+1];

strcpy(name,p.name);

return *this;

}

 

 자 이번장은 조금 길었습니다.... 가내 수작업이라 며칠동안 나눠서 작업을 했네요..ㅜ

슬슬 c++도 마무리 지어가는 것 같습니다! 화이팅!

 

 



반응형