1. 연산자 오버로딩(Operator Overloading)

이번엔 함수 오버로딩, 생성자 오버로딩도 아닌 연산자 오버로딩입니다. 함수 오버로딩, 생성자 오버로딩은 함수명, 생성자명이 같으나, 인자의 자료형이나 수가 다른 함수의 선언을 허용하여 여러 기능을 가진 함수를 제공하는데, 연산자 오버로딩은 그렇다면 기존의 연산자 말고 다른 기능을 제공하는 연산자를 추가할 수 있는 것일까요? 우선 아래의 예제를 먼저 보도록 합시다.

#include <iostream>

using namespace std;

class NUMBOX
{
private:
	int num1, num2;
public:
	NUMBOX(int num1, int num2) : num1(num1), num2(num2) { }
	void ShowNumber() 
	{
		cout << "num1: " << num1 << ", num2: " << num2 << endl;
	}
};

int main()
{
	NUMBOX nb1(10, 20);
	NUMBOX nb2(5, 2);
	NUMBOX result = nb1 + nb2;

	nb1.ShowNumber();
	nb2.ShowNumber();
}

에러:

1 IntelliSense: 이러한 피연산자와 일치하는 "+" 연산자가 없습니다.

            피연산자 형식이 NUMBOX + NUMBOX입니다. c:\Users\h4ckfory0u\Documents\Visual Studio 2012\Projects\ConsoleApplication4\ConsoleApplication4\소스.cpp 21 22 ConsoleApplication4


코드를 보시면, 5~15행에서 NUMBOX란 클래스가 정의되었습니다. 이 NUMBOX 클래스 내에는 멤버 변수 num1, num2과 생성자, 멤버 변수의 값을 각각 출력해주는 ShowNumber 함수가 있습니다. 그리고 19, 20행을 보시면 nb1, nb2 객체가 만들어짐과 동시에 생성자로 값을 넘겨주어 num1과 num2를 초기화 합니다. 그런데, 그 다음 문장이 문제입니다. 21행을 보시면 선언된 result에 객체 nb1과 nb2를 더한 값을 result로 대입하고 있는데, 에러를 보시면 "이러한 피연산자와 일치하는 "+" 연산자가 없습니다. 피연산자 형식이 NUMBOX + NUMBOX입니다."라는 에러가 뜨시는것을 보실 수 있습니다.


그럼, 연산자 오버로딩을 활용하여 기존에 있던 + 연산자가 아닌, NUMBOX 객체 끼리의 덧셈이 가능한 연산자를 추가해보도록 합시다. 연산자 오버로딩을 이해하기 위해, 예제를 한번더 보도록 하겠습니다.

#include <iostream>

using namespace std;

class NUMBOX
{
private:
	int num1, num2;
public:
	NUMBOX(int num1, int num2) : num1(num1), num2(num2) { }
	void ShowNumber() 
	{
		cout << "num1: " << num1 << ", num2: " << num2 << endl;
	}
	NUMBOX operator+(NUMBOX &ref)
	{
		return NUMBOX(num1+ref.num1, num2+ref.num2);
	}
};

int main()
{
	NUMBOX nb1(10, 20);
	NUMBOX nb2(5, 2);
	NUMBOX result = nb1 + nb2;

	nb1.ShowNumber();
	nb2.ShowNumber();
	result.ShowNumber();
}

결과:

num1: 10, num2: 20

num1: 5, num2: 2

num1: 15, num2: 22

계속하려면 아무 키나 누르십시오 . . .


추가된 부분을 중점적으로 보도록 합시다. 우선 15~19행을 보시면, operator+라는 이름이 특이한 함수가 정의되어 있습니다. 안에 보시면 전달받은 객체와 자신의 멤버 변수를 서로 더해 NUMBOX 임시 객체를 만들고, 이 임시 객체가 반환됩니다. 25행을 보시면, 이번에는 + 연산자 오류가 뜨지 않고 정상적으로 result가 초기화됩니다. 29행에서 result의 ShowNumber 함수를 통해 num1과 num2의 값을 출력하는데, 결과를 보시면 정확히 더해져 나옴을 보실 수 있습니다. 이번에는, 25행의 코드를 아래와 같이 살짝 바꿔보도록 합시다.

NUMBOX result = nb1.operator+(nb2);

이렇게 바꾸어도, 정상적으로 컴파일이 됨을 확인할 수 있습니다. 위 코드는, nb1 객체의 operator+ 함수로 nb2 객체를 인자로 넘겨줍니다. 아까의 결과와 같습니다. operator+ 함수를 호출하는 방식을 통하든, + 만 쓰든 결과는 100% 같습니다. 즉, + 연산자를 사용해 NUMBOX 객체를 대상으로 연산을 진행하면 알아서 그 객체의 operator+ 함수가 호출된다는 것입니다. 정리하자면, 아래와 같이 해석되겠죠?


nb1 + nb2 = nb1.operator+(nb2)


위처럼, nb1 + nb2은 nb1.operator+(nb2)로 해석되어 컴파일 됩니다. 물론, operator+가 아니여도 operator<연산자>(operator+, operator-, operator*..)와 같은 형식으로 사용이 가능합니다. 알아두실게 있다면, 모든 연산자들이 오버로딩의 대상이 되는것은 아니며(오버로딩이 불가능한 연산자들이 존재), 기존에 존재하던 연산자의 기능에서 약간 더 추가하는 것뿐, 완전히 새로운 연산자를 만드는것은 아니라는 겁니다.


그런데, 꼭 굳이 두 피연산자가 자료형이 달라도 위와 같은 연산이 가능할까요? 네, 이 역시도 연산자 오버로딩을 통해 쉽게 구현할 수 있습니다. 한번 같이 아래 예제를 보도록 합시다.

#include <iostream>

using namespace std;

class NUMBOX
{
private:
	int num1, num2;
public:
	NUMBOX(int num1, int num2) : num1(num1), num2(num2) { }
	void ShowNumber() 
	{
		cout << "num1: " << num1 << ", num2: " << num2 << endl;
	}
	NUMBOX operator+(int num)
	{
		return NUMBOX(num1+num, num2+num);
	}
};

int main()
{
	NUMBOX nb1(10, 20);
	NUMBOX result = nb1 + 10;

	nb1.ShowNumber();
	result.ShowNumber();
}

결과:

num1: 10, num2: 20

num1: 20, num2: 30

계속하려면 아무 키나 누르십시오 . . .


코드의 15~18행을 보시면, operator+의 매개변수의 자료형이 정수형입니다. 17행을 보시면, 인자가 있는 생성자에게 num1+num, num2+num 값을 넘겨주어 초기화가 진행된 뒤, 이 임시 객체는 소멸합니다. 이어서, 24행을 보시면

정수형과 NUMBOX 객체간의 덧셈이 오류없이 정상적으로 이루어짐을 알 수 있습니다. 27행을 통해 result의 멤버 변수 num1, num2를 출력했더니 10이 더해진 결과를 출력했습니다.


그럼, 아래와 같은 문장도 제대로 연산이 이루어질까요?

NUMBOX result = 10 + nb1;

위와 같이 바꾸었더니, 컴파일러에서 아래와 같은 에러를 내보냅니다.


에러:

1 IntelliSense: 이러한 피연산자와 일치하는 "+" 연산자가 없습니다.

            피연산자 형식이 int + NUMBOX입니다. c:\Users\h4ckfory0u\Documents\Visual Studio 2012\Projects\ConsoleApplication4\ConsoleApplication4\소스.cpp 24 21 ConsoleApplication4


당연히 위와 같은 코드가 허락될리 없습니다. 우리는 클래스 내에 멤버 함수를 정의하고, 이 멤버 함수를 통해 연산자 오버로딩을 수행했습니다. 생각해보면, 10 + nb1는 10.operator+(nb1)과 같은 형태로 인식이 됩니다. 10이란 정수형 데이터에 operator+란 멤버 함수도 없을뿐더러, NUMBOX 객체를 넘기는 형태가 되었습니다. 이 경우에는 이제 배우게 될 전역 함수를 통한 오버로딩으로 해결할 수 있습니다.


2. 전역 함수 오버로딩

멤버 함수를 통한 오버로딩은 "객체.operator+(피연산자), 객체 + 피연산자"식으로 이루어져, 자료형이 다른 두 피연산자를 대상으로 하는 연산시, 반드시 객체가 왼쪽에 위치해야 연산이 가능한 반면에, 전역 함수를 통한 오버로딩은 "operator+(피연산자, 피연산자), 피연산자 + 피연산자"의 식으로 객체가 뒤에 위치해도 정상적인 결과를 출력합니다.


피연산자 + 피연산자 = operator+(피연산자, 피연산자)


한번, 전역 함수를 통한 오버로딩이 어떤 것인지 예제를 하나 보도록 합시다.

#include <iostream>

using namespace std;

class NUMBOX
{
private:
	int num1, num2;
public:
	NUMBOX(int num1, int num2) : num1(num1), num2(num2) { }
	void ShowNumber() 
	{
		cout << "num1: " << num1 << ", num2: " << num2 << endl;
	}
	NUMBOX operator+(int num)
	{
		return NUMBOX(num1+num, num2+num);
	}
	friend NUMBOX operator+(int num, NUMBOX ref);
};

NUMBOX operator+(int num, NUMBOX ref)
{
	ref.num1 += num;
	ref.num2 += num;

	return ref;
}

int main()
{
	NUMBOX nb1(10, 20);
	NUMBOX result = 10 + nb1 + 40;

	nb1.ShowNumber();
	result.ShowNumber();
}

결과:

num1: 10, num2: 20

num1: 60, num2: 70

계속하려면 아무 키나 누르십시오 . . .


코드를 먼저 살펴보시면, 19행에 operator+ 함수가 선언되었습니다. friend 키워드가 붙은 이유는, 이 함수가 클래스의 멤버 함수가 아니기 때문에 멤버 변수에 접근할 수 없으므로 붙여준 것입니다. 그리고 22~27행을 보시면 전역 함수로 operator+ 함수가 추가로 정의되었습니다. 33행은 22~27행의 operator+ 함수를 통해 operator+(10, nb1)으로 인식됩니다. 이어서 main 함수 내의 33행을 다시한번 보시면 10과 nb1을 더하고 그 결과에서 40을 더해 result에 대입하고 있습니다. 36행을 통해 결과를 확인해보면 정상적으로 덧셈 연산이 이루어졌음을 확인할 수 있습니다.


3. 단항 연산자 오버로딩

이번엔 단항 연산자 오버로딩을 잠시 보도록 할텐데, 그 중에서도 증가, 증감 연산자의 오버로딩을 살펴보도록 하겠습니다. 그리고 후위 증가와 전위 증가는 어떻게 구분을 하는지도 알아보도록 하겠습니다. 아래 예제를 통해, 증가, 증감 연산자는 어떤식으로 오버로딩 하는지 살펴봅시다.

#include <iostream>

using namespace std;

class NUMBOX
{
private:
	int num1, num2;
public:
	NUMBOX() { }
	NUMBOX(int num1, int num2) : num1(num1), num2(num2) { }
	void ShowNumber() 
	{
		cout << "num1: " << num1 << ", num2: " << num2 << endl;
	}
	NUMBOX operator++()
	{
		num1+=1;
		num2+=1;
		return *this;
	}
	NUMBOX operator++(int)
	{
		NUMBOX temp(*this);
		num1+=1;
		num2+=1;
		return temp;
	}
};

int main()
{
	NUMBOX nb1(10, 20);
	NUMBOX nb2;

	nb2 = nb1++;
	nb2.ShowNumber();
	nb1.ShowNumber();
	
	nb2 = ++nb1;
	nb2.ShowNumber();
	nb1.ShowNumber();
}

결과:

num1: 10, num2: 20

num1: 11, num2: 21

num1: 12, num2: 22

num1: 12, num2: 22

계속하려면 아무 키나 누르십시오 . . .


우선은, 코드의 16~21행부터 살펴봅시다. 16~21행에서 ++ 연산자가 멤버 함수의 형태로 오버로딩 되었음을 보실 수 있습니다. 안을 살펴보면, num1에 1을 더하고, num2에 1을 더하고, this가 아닌 *this를 반환합니다. (this는 객체의 주소를, *this는 this 포인터가 가리키는 객체, 실질적인 데이터를 의미합니다.) 그리고 이런 형식은 전위 증가 연산입니다.


이어서, 22~28행을 살펴보면 ++ 연산자가 멤버 함수의 형태로 오버로딩 되었으나, 위와는 달리 인수 목록에 int 타입이 등장합니다. 이는, C++에서 전위 또는 후위 연산에 대한 구분 규칙에 의한 것이며, 전위와 후위는 아래와 같이 구분합니다.

++nb = nb.operator++(); // 전위 증가 연산
nb++ = nb.operator++(int); // 후위 증가 연산

주의하실 부분은, int는 그저 전위 증가 연산과 후위 증가 연산을 구분하는 기준일 뿐, int 타입의 데이터를 인자로 전달한다는 뜻으로 오해하지 마시기 바랍니다. 다시 돌아가서, 24행을 보시면 NUMBOX 객체를 만들어두고 인자로 *this를 넘깁니다. 이 문장은 "NUMBOX temp(num1, num2)"와 같습니다. 25~26행에선 num1, num2의 값을 1씩 증가시키고 27행에선 기존의 값을 담은 temp를 반환합니다.


main 함수로 들어가, 36행을 보시면 nb1의 후위 증가 연산이 이루어지고, 반환된 값이 nb2에 대입됩니다. 40행에선 nb1의 전위 증가 연산이 이루어지고, 반환된 값이 nb2에 대입되구요. 결과를 보시면, 전위 증가와 후위 증가가 정상적으로 이루어짐을 확인하실 수 있습니다.


4. 대입 연산자 오버로딩

마지막으로, 대입 연산자 오버로딩에 대해 알아보도록 하겠습니다. 아래 예제를 보면서 진행하도록 합시다.

#include <iostream>

using namespace std;

class A
{
private:
	int num1, num2;
public:
	A() { } // 디폴트 생성자
	A(int num1, int num2) : num1(num1), num2(num2) { }
	void ShowData() { cout << num1 << ", " << num2 << endl; }
};

class B
{
private:
	int num1, num2;
public:
	B() { }
	B(int num1, int num2) : num1(num1), num2(num2) { }
	void ShowData() { cout << num1 << ", " << num2 << endl; }
};

int main()
{
	A a1(10, 50);
	A a2;
	B b1(10, 20);
	B b2;

	a2 = a1;
	b2 = b1;

	a2.ShowData();
	b2.ShowData();
	return 0;
}

결과:

10, 50

10, 20

계속하려면 아무 키나 누르십시오 . . .


코드를 보시면, 5~13행에서 A란 클래스가 정의되었고, 15~23행에서 B란 클래스가 정의되었습니다. 이어서, main 함수 안을 보시면 27행에서 a1 객체가 만들어짐과 동시에 생성자에게 10과 50을 각각 전달하고, 멤버 변수 num1, num2를 초기화 합니다. 그 다음행인, 28행에서는 a2 객체가 만들어지고 디폴트 생성자가 호출됩니다. (초기화되지 않음) 29~30행도 마찬가지로 생성과 동시에 멤버 변수가 초기화된 b1 객체와, 그렇지 않은 b2 객체로 나뉩니다.


그 다음에, 32~33행을 보시면 대입 연산자가 쓰였는데, 이상하게도, 우리가 대입 연산자를 정의하지 않았음에도 이런 문장은 정상적으로 멤버 대 멤버 복사가 이루어집니다. 사실은, 디폴트 복사 생성자와 같이, 대입 연산자가 정의되지 않으면 디폴트 대입 연산자가 삽입이 되는 것입니다. 그리고, 멤버 대 멤버 복사를 수행할때 깊은 복사가 아닌 얕은 복사를 진행합니다.

#include <iostream>

using namespace std;

class Student
{
private:
	char * name;
	int age;
public:
	Student(char * name, int age) : age(age) 
	{
		this->name = new char[10];
		strcpy(this->name, name);
	}
	void ShowInfo() {
		cout << "이름: " << name << endl;
		cout << "나이: " << age << endl;
	}
	~Student()
	{
		delete []name;
		cout << "~Student 소멸자 호출!" << endl;
	}
};

int main()
{
	Student st1("김철수", 14);
	Student st2("홍길동", 15);

	st2 = st1;

	st1.ShowInfo();
	st2.ShowInfo();
	return 0;
}

결과:

이름: 김철수

나이: 14

이름: 김철수

나이: 14

~Student 소멸자 호출!

계속하려면 아무 키나 누르십시오 . . .


코드를 보시면, 5~25행에 Student 클래스가 정의되었습니다. 안을 보시면, 이름을 나타내는 name, 나이를 나타내는 age 멤버 변수가 존재합니다. 그리고 생성자를 보시면, 멤버 이니셜라이저를 통해 age를 초기화 하고, this->name에 길이가 10인 char형 공간을 할당해주고, 인자로 받은 name을 this->name로 복사합니다. 20~24행은 소멸자가 정의되었는데, 소멸자 안을 살펴보시면 따로 할당한 name을 메모리 공간에서 해제하고, 소멸자가 호출되었음을 알리기 위해 "~Student 소멸자 호출!"을 화면에 출력하게 했습니다.


main 함수로 내려와, 29~30행에서 st1, st2 객체가 생성됨과 동시에 멤버 변수 초기화를 했습니다. 32행에서 디폴트 대입 연산자에 의해 멤버 대 멤버 복사가 이루어지는데, 여기서 문제가 발생합니다. 복사가 이루어지면서 st2는 "홍길동"이 아닌 "김철수"란 문자열이 담긴 주소를 가리키고, "홍길동"이란 문자열은 접근도, 소멸도 불가능 해지는 상황이 벌어집니다. 또한, 두 객체의 소멸자가 호출될 때 st1, st2 객체 모두 "김철수"란 문자열이 담긴 주소를 가리키고 delete를 통해 소멸할 때 중복 소멸하는 문제가 일어납니다.


이것을 해결하기 위해선 어떻게 해야할까요? 얕은 복사가 아닌 깊은 복사를 정의하면 됩니다. 아래와 같이 말이죠.

#include <iostream>

using namespace std;

class Student
{
private:
	char * name;
	int age;
public:
	Student(char * name, int age) : age(age) 
	{
		this->name = new char[10];
		strcpy(this->name, name);
	}
	void ShowInfo() {
		cout << "이름: " << name << endl;
		cout << "나이: " << age << endl;
	}
	Student& operator=(Student& ref)
	{
		delete []name;
		name = new char[10];
		strcpy(name, ref.name);
		age = ref.age;
		return *this;
	}
	~Student()
	{
		delete []name;
		cout << "~Student 소멸자 호출!" << endl;
	}
};

int main()
{
	Student st1("김철수", 14);
	Student st2("홍길동", 15);

	st2 = st1;

	st1.ShowInfo();
	st2.ShowInfo();
	return 0;
}

결과:

이름: 김철수

나이: 14

이름: 김철수

나이: 14

~Student 소멸자 호출!

계속하려면 아무 키나 누르십시오 . . .


바뀐 부분만 중점적으로 보도록 합시다. 20~27행을 보시면 대입 연산자를 정의(연산자 오버로딩)하고 있습니다. 안을 살펴보면, 22행에서 메모리 누수를 막기 위해 name을 메모리 공간에서 해제시키고, 23행에서 새로 공간을 할당합니다. 그러고 나서, 24행에서 strcpy 함수를 통해 ref.name을 name에 복사시킵니다. 25행에서는, age에 ref.age를 대입하고, 객체가 담고있는 값을 반환합니다.


이러게 되면, "홍길동"란 문자열이 해제되지 않고 메모리 공간에 남아있는 문제를 해결할 수 있고(22행의 delete 연산), 정의된 대입 연산자에 의해 복사가 이루어지고 st1의 "김철수"와 st2의 "김철수"는 서로 다른곳을 가리키게 됩니다. 이해 하셨나요?


이번 강좌는 여기서 그만 마치도록 하겠습니다. 수고하셨습니다. 다음 강좌에서는 템플릿(Template)에 대해 알아보도록 하겠습니다.