연산자 오버로딩(Operator Overloading)은 C++ 문법과 우리와의 약속이라고 할 수 있는데, 우리가 함수를 정의하는데 이어서 operator라는 키워드와 연산자를 붙여서 함수의 이름을 만들 경우에 (operator + 이렇게 ) 우리가 일반적인 함수를 호출 방법 말고도, 연산자만을 이용해서도 함수가 호출 되도록 해주겠다는 이야기이다.
1. 멤버 함수에 의한 오버로딩
- #include <iostream>
- using namespace std;
- class Point {
- private:
- int x, y;
- public:
- Point(int _x=0, int _y=0):x(_x), y(_y){}
- void ShowPosition();
- Point operator+(const Point& p);
- };
- void Point::ShowPosition(){
- cout<<x<<" "<<y<<endl;
- }
- Point Point::operator+(const Point& p){
- Point temp(x+p.x, y+p.y);
- return temp;
- }
- int main(void)
- {
- Point p1(1, 2);
- Point p2(2, 1);
- Point p3=p1+p2;
- p3.ShowPosition();
- return 0;
- }
그럼 위의 소스 코드 24번째 줄의 p1+p2:는 어떤의미를 갖는지 알아 보자. 여기서 p1 , p2는 객체 이므로 기본적으로 '+' 덧셈 연산이 불가능하다. 그래서 C++은 어떤 약속을 하였느냐 하면, '+' 라는 사칙연산을 할려고 하는게 아니라, 앞에 operator 라는 키워드를 붙여서 p1과 p2를 이용해서 operator +라는 함수를 호출해주게 되는 것이다.
그래서 이제 C++은 'operator +' 라는 함수를 호출 해야 하는데, C++에서는 함수를 만드는 방법 멤버 함수, 전역함수 두가지가 있다. 그러므로 'operator +'는 이 둘로 정의 될 수 있는 것이다. 우리가 멤버로 만들건, 전역으로 만들건 C++은 'operator +'에 대해 알아서 처리해줄 수 있다. 그럼 멤호 함수에 의한 오버로딩은 어떻게 일어 나는지 알아 보자.
24번째 라인의 p1 + p2; 이것은 "이항 연산자의 왼쪽에 오는 객체의 operator + 함수를 호출 하면서, 이항연산자의 오른쪽의 피연산자를 인자로 전달한다" 라는 의미가 된다. 즉
- p1.operator +(p2);
이렇게 바뀐다는 말이다.
덧셈 연산이 끝나면 p1+p2 자리에 temp 라는 이름의 객체의 복사본이 리턴되서 들어 오게 된다. 그리고 리턴된 값은 p3를 초기화 해준다. 여기서 temp 객체는 포인터 클래스의 객체 이므로, 이 경우의 복사 생성자가 호출 되는 것도 알 수 있다. temp 함수의 연산에 의해서 아래와 같이 결과가 나오는 것도 알 수 있다.
2. 전역 함수에 의한 오버로딩
- #include <iostream>
- using namespace std;
- class Point {
- private:
- int x, y;
- public:
- Point(int _x=0, int _y=0):x(_x), y(_y){}
- void ShowPosition();
- friend Point operator+(const Point& p1, const Point& p2);
- };
- void Point::ShowPosition(){
- cout<<x<<" "<<y<<endl;
- }
- Point operator+(const Point& p1, const Point& p2) //전역함수다
- {
- Point temp(p1.x+p2.x, p1.y+p2.y);
- return temp;
- }
- int main(void)
- {
- Point p1(1, 2);
- Point p2(2, 1);
- Point p3=p1+p2;
- p3.ShowPosition();
- return 0;
- }
위의 소스코드는 전역함수를 friend 선언해주고있다. friend 선언을 해줌으로서, operator+ 함수를 포인트 클래스 객체의 private 멤버에 직접 접근이 가능하다. (friend는 연산자 오버로딩에 주로 사용된다.)
그럼 전역함수에서는 p1 + p2; 가 C++의 약속에 의해 어떤 식으로 해석이 되는 것일까? 바로 아래와 같이 해석된다.
- operator+ (p1, p2);
전역함수는 함수 이름만 있어도 호출이 가능하므로, 위와 같은식으로 해석되는 것을 알 수 있다. 여기까지 멤버함수, 전역 함수에 의한 오버로딩을 알아 봤다.
이전에는 사칙연산인 이항연산자 오버로딩에 대해서 알아 봤는데 이번에는 단항 연산자인 증가(++), 감소(--) 연산자 오버로딩에 대해서 알아 보자. 만약 P객체가 증가가 된다면 멤버함수와 전역함수에서는 C++의 약속에 의해 어떻게 표현 될까? 이전의 이항 연산자 오버로딩때와 마찬가지로 아래와 같이 됨을 알 수 있을 것이다.
- p.operator++ ( ) //멤버
- operator ++(p) //전역
그럼 단항 연산자 오버로딩의 예제 소스코드를 한번 살펴보자.
- #include <iostream>
- using namespace std;
- class Point {
- private:
- int x, y;
- public:
- Point(int _x=0, int _y=0):x(_x), y(_y){}
- void ShowPosition();
- Point& operator++(); //멤버 함수
- friend Point& operator--(Point& p);
- };
- void Point::ShowPosition(){
- cout<<x<<" "<<y<<endl;
- }
- Point& Point::operator++() //멤버함수 정의
- {
- x++;
- y++;
- return *this;
- }
- Point& operator--(Point& p) //전역함수
- {
- p.x--;
- p.y--;
- return p;
- }
- int main(void)
- {
- Point p(1, 2);
- ++p;
- p.ShowPosition();
- --p;
- p.ShowPosition();
- ++(++p);
- p.ShowPosition();
- --(--p);
- p.ShowPosition();
- return 0;
- }
- void main()
- {
- int n;
- ++(++n);
- }
따라서 ++(++p) 연산에서도 괄호 안의 연산인 p.operator++() 함수 호출이 끝나고 나서 이 자리에 p가 그대로 리턴이 돼야 이런 증가 연산이 제대로 이루어 질 수 있다.
- Point& Point::operator++() //멤버함수 정의
- {
- x++;
- y++;
- return *this;
- }
멤버 함수 정의부를 보면 return 타입이 *this인 것을 알 수 있다. 여기서의 this는 Point 객체의 포인터를 의미하는데, *를 붙였으니까 Point객체 자신을 의미한다. 그럼 나 자신을 무엇으로 리턴 하느냐? 바로 Point& 즉, 참조로 리턴을 하는 것이다.
괄호 안은 p.operatr++()이 되고 이 연산이 끝나고 p의 참조를 리턴하는데 p의 참조는 p와 같다. ++(p참조) -> p참조 . operator++() 이 것이 된다. p참조는 p자신 이므로, p.operator++() 이 되는 것과 같다. 그래서 결과가 ' 3 4 '로 연산이 제대로 되는 것이다.
만약 참조로 리턴 안하고 Point로 리턴 한다면 어떻게 될까?
- #include <iostream>
- using namespace std;
- class Point {
- private:
- int x, y;
- public:
- Point(int _x=0, int _y=0):x(_x), y(_y){}
- void ShowPosition();
- Point operator++(); //멤버 함수
- friend Point operator--(Point& p);
- };
- void Point::ShowPosition(){
- cout<<x<<" "<<y<<endl;
- }
- Point Point::operator++() //멤버함수 정의
- {
- x++;
- y++;
- return *this;
- }
- Point operator--(Point& p) //전역함수
- {
- p.x--;
- p.y--;
- return p;
- }
- int main(void)
- {
- Point p(1, 2);
- ++p;
- p.ShowPosition();
- --p;
- p.ShowPosition();
- ++(++p);
- p.ShowPosition();
- --(--p);
- p.ShowPosition();
- return 0;
- }
그냥 Point를 리턴 한다면, 나 자신을 복사 해서 리턴 하는것이다. 그래서 ++(++p) 에서 괄호 안의 연산후에 이 자리에 오는 것은 p가 아니라 p객체의 복사본이 리턴되는것이다. 그리고 p객체의 복사본을 가지고 ++연산을 하게 되는것이다. 결과적으로 리턴하는 순간에 새로운 객체를 만들어서 리턴하는 꼴이 되므로 p객체는 단지 한번만 증가 하게 되는 것이다. (이것이 문제가 된다)
4. 선연산과 후연산의 구분
단항 연산자 ++, --를 변수나 객체의 앞에 붙이느냐 뒤에 붙이느냐에 따라 증가를 먼저 하고 연산을 할것인가, 연산을 먼저 한 후, 값을 증가 시킬 것인지 나뉘게 된다.
- ++ p : 변수의 값을 먼저 증가 시킨후 연산
- p++ : 연산후 값 증가
우리가 단항 연산자 오버로딩을 설계할 때, 이처럼 문법상 기본 자료형이 하는 일을 따라 간다면 우리는 선연산 후연산이 다르게 동작 되어야 하는것을 알 수 있는 것이다. 그래서 우리는 호출 되는 함수의 구분을 위해 C++은 또 하나의 약속을 해야 하는 것이다. 다음과 같이 말이다.
- ++p 은 p.operator ++( );
- p++ 은 p.operator ++(Data Type);
여기서 선언된 데이터 타입은(int, double 등등의...) ++ 연산을 구분 지어 주기 위해서만 의미를 가진다. 예제 소스를 보자.
- #include <iostream>
- using namespace std;
- class Point {
- private:
- int x, y;
- public:
- Point(int _x=0, int _y=0):x(_x), y(_y){}
- void ShowPosition();
- Point& operator++(); //++p
- Point operator++(int); // p++
- };
- void Point::ShowPosition(){
- cout<<x<<" "<<y<<endl;
- }
- Point& Point::operator++(){
- x++;
- y++;
- return *this;
- }
- Point Point::operator++(int){
- Point temp(x, y); // Point temp(*this);
- x++;
- y++;
- return temp;
- }
- int main(void)
- {
- Point p1(1, 2);
- (p1++).ShowPosition();
- p1.ShowPosition();
- Point p2(1, 2);
- (++p2).ShowPosition();
- return 0;
- }
결가 값을 보면 (p1++).ShowPosition(); 이 부분에 1 2 값을 출력하는 것을 알 수 있다. p1객체 뒤에 증가 연산자가 왔으므로, 이것은 후 증가 의미를 가지기 때문이다. 그후 p1을 출력해보면, 후 증가의 값을 눈으로 확인해볼 수 있는 것이다.
그럼 증가 하기 이전의 값을 얻기 위한 함수는 어떻게 만들까? 그 함수는 바로 이 부분이다.
- Point Point::operator++(int){
- Point temp(x, y); // Point temp(*this);
- x++;
- y++;
- return temp;
- }
증가하기 이전에 값을 만든 다음에 (temp) 그 다음에 값을 증가 시키고,
리턴 할때는 증가 하기 이전에 객체를 이전하면 된다. 이 함수의 경우에는 참조로 리턴이 안됐는데, 이 함수는
참조로 리턴 할 수 없다.
왜냐? 참조로 리턴(return) 할 수 없는것이 무엇인가? 바로 지역 변수, 지역객체 이다. 여기서 temp는
지역적으로 선언된것이므로 참조로 리턴 될 수 없다. 만약 리턴 된다고 해도 리턴 되고 나서 이 리턴값은 바로 사라지기 때문이다. 이게 바로 연산자 오버로딩의 한계점이라고 할 수 있다.
3 + 2 라는 연산이 있다. 여기에서 피연산자 위치를 다르게 바꿔도 ( 2 + 3 ) 동일한 결과를 가져오게 하는 법칙이 바로 교환법칙이다. 우리는 연산자 오버로딩을 사용함에 있어서 이렇게 교환법칙도 성립할 수 있게 만들어야 한다. 아래의 연산자 오버로딩을 보자.
- #include <iostream>
- using namespace std;
- class Point {
- private:
- int x, y;
- public:
- Point(int _x=0, int _y=0):x(_x), y(_y){}
- void ShowPosition();
- Point operator+(int val); //operator+라는 이름의 함수
- };
- 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;
- }
23번째 라인의 Point p2=p1+3; 이 문장은 C++에서는 p1.operator+( 3 ) 이와 같이 인식되는것을 우리는 앞서 알아봤었다. 교환법칙이 성립되려고 하면 다음과 같이 써도 에러가 나지 않아야 한다.
- Point p3 = 3 + p1;
(Visual Studio 2010에서는 컴파일 하지 않아도 에러를 실시간으로 잡아내는 기능이 추가 되었다)
그럼 왜 이런 교환법칙이 성립이 안되는 것일까? 자세히 한번 알아보자.
- Point p3 = 3 + p1;
여기서 바로 문제가 생긴다. 숫자 3은 int형 데이터로 operator+라는 함수가 멤버로서 존재할리 없다. 이 문장은 논리적 으로 문제가 있는 것이다. 그러면 우리는 교환법칙이 성립 되도록 추가적인 코드를 만들어야 한다.
: 우리는 멤버 함수 오버로딩으로는 문제가 있다는 것을 알았다. 그러면 전역 함수 오버로딩시에는 어떻게 될까? Point p3 = 3 + p1; 이 문장은 전역함수에서 인식될때 왼쪽에 있는 피연산자가 첫번째 인자로, 오른쪽에 피연산자가 두번째 인자로 가기 때문에 operator+ (3, p); 로 C++에서 인식되고 이것은 논리적으로 문제가 전혀 없다. 결론이 나왔다. 우리는 교환법칙을 성립 해주기 위해서 전역함수 오버로딩을 제공해주기만 하면 되는것이다. 바로 아래와 같이 말이다. 결과도 이상없이 출력되는 것을 알 수 있다.
- #include <iostream>
- using namespace std;
- class Point {
- private:
- int x, y;
- public:
- Point(int _x=0, int _y=0):x(_x), y(_y){}
- void ShowPosition();
- Point operator+(int val); //operator+라는 이름의 함수
- friend Point operator+(int val, const Point& p);
- };
- void Point::ShowPosition() {
- cout<<x<<" "<<y<<endl;
- }
- Point Point::operator+(int val) {
- Point temp(x+val, y+val);
- return temp;
- }
- Point operator+(int val, Point& p)
- {
- return p+val;
- }
- int main(void)
- {
- Point p1(1, 2);
- Point p2=p1+3;
- p2.ShowPosition();
- Point p3=3+p2;
- p3.ShowPosition();
- return 0;
- }
쉬프트 연산자란, 우리들이 흔히 쓰는 cout, cin, endl 키워드를 쓸때 앞이나 뒤에 붙이는 연산자이다. 일반적으로 표준 라이브러리에 std라는 namespace에는 cout이나 cin에 관련된 클래스들이 있다. 그것이 바로 istream과 ostream이다. 이런 namespace를 일일이 입력하기 싫어서 using namespace 라는 간편한 문법을 쓰기도 한다. C++에서는 이런 쉬프트 연산자도 연산자 오버로딩이 가능하다.
- int main(void)
- {
- Point p(1, 3);
- cout<<p;
- return 0;
- }
cout.operator<< (p) 즉, p객체를 인자값을 받을 수 있도록 cout 객체에는 operator<< 연산자가 오버로딩 되어 있어야 하는데, 표준 std의 ostream 클래스에서도 쉬프트 연산에 대한 오버로딩이 정의 되어 있지 않다. 그래서 우리가 표준 라이브러리를 건드리면서까지 객체를 출력하는 일은 불가능 할 것이다.
이로서 멤버 함수로의 오버로딩으로는 객체를 출력할 수 없다는 것을 알 수 있었다. 그럼 전역 함수의 오버로딩을 보자.전역함수로의 오버로딩에서는 cout << p 문장은 operator (cout, p); 로 풀어 써지고, 이것은 논리적으로 문제가 없다. 그럼 전역함수로 오버로딩으로 다음과 같이 함수를 정의 할 수 있겠다.
- #include <iostream>
- using namespace std;
- 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)
- {
- os<<"["<<p.x<<", "<<p.y<<"]"<<endl;
- return os;
- }
- int main(void)
- {
- Point p(1, 3);
- cout<<p;
- return 0;
- }
- A a = b;
- A a;
- B b;
- a = b;
- #include <iostream>
- using namespace std;
- 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);
- Point& operator = (const Point &p)
- {
- x = p.x;
- y = p.y;
- return *this;
- }
- };
- ostream& operator<<(ostream& os, const Point& p)
- {
- os<<"["<<p.x<<", "<<p.y<<"]";
- return os;
- }
- void main()
- {
- Point p1(1, 3);
- Point p2(10, 30);
- cout<<p1<<endl;
- cout<<p2<<endl;
- p1=p2;
- cout<<p1<<endl;
- }
위에서 말한것과 같이, 31번째 라인에서는 a.operator=(b); 이런 형태로 대입연산 오버로딩이 일어날 것입니다. 하지만 여기 소스코드에서는 어디에서도 대입 연산을 정의 하는 함수를 찾아 볼 수 없다. 그것은 바로 대엽 연산자 오버로딩에 대해서만 "디폴트 대입 연산자"가 존재하기 때문이다. 그래서 우리가 명시적으로 써주지 않아도 이 디폴트 대입 연산자를 호출해 문제가 발생하지 않는 것이다.
만약 우리가 정의 한다고 하면 아래와 같이 정의 할 수 있겠다.
- Point& operator = (const Point & p);
- {
- x = p.x; y = p.y;
- return *this;
- }
- #include <iostream>
- using namespace std;
- 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("One");
- Person p2("Two");
- cout<<p1<<endl;
- cout<<p2<<endl;
- p1=p2; //error
- cout<<p1<<endl;
- return 0;
- }
위의 소스 코드를 실행해 보면 컴파일에는 문제가 없지만, 런타임에서 문제가 생긴다. 그럼 이런 문제는 왜 일어 나는 것일까?
여기서는 Person 클래스의 p1과 p2 객체를 생성하는데 p1과 p2는 객채 내에 name이라는 포인터가 있어서 문자열의 길이 만큼 동적 할당을하고 이 저장한 공간을 p1 객체가 포인터를 유지하는 형태를 띠게 되는데, 동적할당은 Heap 영역에 p1과 p2객체는 스택에 생성이 된다.
그 후, 40번째 라인의 p1 = p2; 은 p1.operator=(p2) 이것으로 해석 되므로, 대입 연산자 오버로딩이 일어 날 것이다. 우선 여기서는 대입 연산자가 따로 정의 되어 있지 않으므로, 디폴트 대입 연산자를 사용해서 p2가 인자로 전달되어 멤버간의 복사가 일어 날 것이다. (p2가 지니는 값을 p1에 복사하는 것이다.) 하지만 p2의 멤버는 객체의 포인터이다. 그래서 객체의 포인터 값을 p1에다 복사 할텐데, 처음 가리키고던 "One"이 끊어지고 p1은 "Two"를 가리키게 된다. 이게 바로 문제다.
이 문제는 복사 생성자에서도 생겼던 문제 인데, 하나의 문자열을 두개의 객체가 참조 하다 보니까 소멸자 호출에 있어서 문제가 생기고,(이것은 복사생성자에서도 생겼던 문제) 포인터를 잃어 버렸기 때문에 프로그램 종료될때까지 메모리 공간에서 메모리를 잡아 먹게 되어 메모리 유출이 발생하는 문제가 발생된다. 그래서 이 경우에는 대입 연산자를 꼭 재정의 해줘야 한다.
일단 포인터가 끊어지고 생기는 메모리 유출에 대한 문제를 해결하고, 메모리 공간을 다시 할당해 복사를 해주자. 아래는 새로 정의된 대입 연산자 함수와 결과 출력이다. 이제는 문제 없이 출력되는 것을 알 수 있다.
- Person& Person::operator=(const Person& p)
- {
- delete []name;
- name= new char[strlen(p.name)+1];
- strcpy(name, p.name);
- return *this;
- }
It was really helpful to understand operator overloading. Thx
답글삭제