C++/C++ 기초

[C++] 연산자 중복 (Operator overloading) + 프렌드(friend) 개념

Song 컴퓨터공학 2023. 7. 13. 15:37

연산자 중복을 배우기에 앞서 먼저 알아야 하는 개념이 있습니다. 바로 friend 라는 키워드로, 다른 언어에는 좀처럼 찾아보기 힘든 개념입니다. C++에서는 클래스 멤버로 선언하지 않아도, 멤버에 접근할 수 있도록 friend 키워드를 제공합니다.

외부에 작성된 함수를 클래스 내에 friend 키워드로 선언하여, 클래스의 멤버 함수와 동일한 접근 자격을 부여할 수 있습니다.

 

클래스 내에 friend 키워드로 선언된 외부 함수프렌드 함수(friend function) 이라고 부르며, 클래스 내에 선언할 수 있는 프렌드 함수의 개수 제한은 없습니다. 어차피 밑의 연산자 중복에서 프렌드 함수를 사용하는 예시가 많이 나오니 프렌드 함수에 대한 예시 코드는 생략하도록 하겠습니다.

 

 

[목차]

1. 연산자 중복

2. 이항 연산자

3. 단항 연산자

4. << , >> 연산자 중복


 

 연산자 중복 (Operator overloading)

 

함수 중복은 시그니처가 다른 같은 이름의 함수를 여러 개 만드는 것이었습니다. 연산자 중복도 이와 완전히 동일한 개념으로 피연산자에 따라 서로 다른 연산을 하도록 동일한 연산자를 중복해서 작성하는 것이 연산자 중복(operator overloading) 입니다. 이미 이전에 이와 비슷한 것을 활용한 적이 많은데요, + 연산자는 int형일 때는 정수 덧셈, double 형일때는 실수 덧셈, string 일때는 append 연산 으로 처리됩니다. 

 

또한 cin 과 같이 사용되는 >> 연산자나 cout 과 활용되는 << 연산자는 원래 비트 이동 연산자지만, 스트림 객체들에 대해 사용할 때는 다른 역할을 하죠. 이렇듯 같은 연산자가 여러 기능을 하게 되는데, 이를 우리가 피연산자에 적합한 연산자를 새로 작성하고 직접 선언해서 사용할 수 있습니다. 

 

연산자 중복은 이름 그대로, 연산자 창조가 아닌 연산자 중복이기 때문에 원래 있는 연산자만 중복 가능합니다. 기본적으로 제공하는 연산자에 새로운 연산 처리를 추가하는 것이기 때문이죠. 그렇기 때문에 원래의 연산자 우선순위 또한 바꿀 수 없습니다. 또한 연산자 중복은 피연산자의 타입이 다른 연산을 새로 정의하는 것입니다.

 

연산자 중복은 연산자 함수를 통해 구현됩니다. 연산자 함수는 2가지 방법으로 작성할 수 있습니다.

 

클래스의 멤버 함수로 구현 vs 외부 함수로 구현하고 클래스의 프랜드 함수로 선언

 

// 클래스의 멤버 함수로 선언
class Color{
	...
    Color operator + (Color op2); 	// 왼쪽 피연산자는 객체 자신
    bool operator == (Color op);	// 왼쪽 피연산자는 객체 자신
};

// 외부함수로 구현하고 클래스에 프렌드 함수로 선언
Color operator + (Color op1, Color op2);
bool operator == (Color op1, Color op2);
...
class Color{
...
	friend Color operator + (Color op1, Color op2);
	friend bool operator == (Color op1, Color op2);
};

기본적으로 연산자 함수는 " 리턴 타입 operator 연산자(매개변수리스트); " 로 선언합니다. 또한 2가지 방법으로 구현한 것의 차이점이 있습니다.

 

첫 번째, 클래스의 멤버 함수로 구현한 경우 지금 매개변수가 한개 입니다. 하지만 저건 실제로 매개변수 1개에 대해서 연산을 수행하는 것이 아닌, 객체 자기 자신까지 연산대상으로 사용하기 때문에 같은 연산임에도 외부 선언에 비해 매개변수가 1개 적습니다.

 

반면 외부 선언은 선언할 때 연산의 대상이 되는 매개변수를 모두 매개변수 리스트에 포함시켜야 합니다.

 

 

 

 

 이항 연산자 중복

 

이항 연산자(Binary operator)란 피연산자가 2개인 연산자를 말합니다. 가장 일반적인 연산자죠.

class Power {
	int kick;
	int punch;
public:
	Power(int kick = 0, int punch = 0) :kick(kick), punch(punch) {}
};

앞으로의 연산자 설명은 위 클래스를 기반으로 설명합니다.

 

+ 연산자 중복

두 개의 Power 연산자를 더하는 + 연산자를 선언하고 구현해봅시다. 연산자를 따로 중복하지 않은 상태에서 아래처럼 코드를 짜면 당연히 오류가 발생합니다.

#include <iostream>
using namespace std;

class Power {
	int kick;
	int punch;
public:
	Power(int k = 0, int p = 0) : kick(k), punch(p) {};
	void show() { cout << "kick = " << kick << ',' << "punch = " << punch << endl; }
};

int main()
{
	Power a(3, 5), b(4, 6), c;
	c = a + b; // 정의 되지 않은 연산. 오류 발생!
	a.show();
	b.show();
	c.show();
}

따라서 클래스 내부의 멤버 함수로 operator + 를 구현해보면

class Power {
	int kick;
	int punch;
public:
	Power(int k = 0, int p = 0) : kick(k), punch(p) {};
	void show() { cout << "kick = " << kick << ',' << "punch = " << punch << endl; }

	// + 연산자 중복 선언 및 구현. 더한 값을 가지는 객체 생성자를 반환
	Power operator+(Power op) { return Power(kick + op.kick, punch + op.punch); }
};

리턴타입 operator 연산자(매개변수) { return 생성자(kick에 들어갈 값, punch에 들어갈 값); } 으로 아주 간단하게 선언과 구현을 할 수 있습니다. 이를 추가하고 코드를 수행시켜보면 아래처럼 + 연산자로 각 객체의 멤버 변수를 더한 값을 가지는 객체를 출력할 수 있습니다.

위와 완전히 동일한 연산자 중복 함수를 외부에서 구현하고, 클래스 내에 friend 로 선언해도 동일한 결과를 얻을 수 있습니다. 다른 점이면, 외부에서 선언되었기 때문에 이항 연산자의 경우 매개변수가 2개가 됩니다. 멤버로 선언했을 때는 자기 자신 까지 사용해도 되기 때문에 1개의 매개변수였지만, 외부 선언의 경우 자기 자신의 멤버를 사용하지 않기 때문에 2개의 객체를 입력으로 받아 연산하고 1개의 객체를 반환합니다.

class Power {
	int kick;
	int punch;
public:
	Power(int k = 0, int p = 0) : kick(k), punch(p) {};
	void show() { cout << "kick = " << kick << ',' << "punch = " << punch << endl; }

	// + 연산자 중복 friend 선언
	friend Power operator+(Power op1, Power op2);
};

// 연산자 중복 함수 선언 및 외부 구현
Power operator+(Power op1, Power op2) { return Power(op1.kick + op2.kick, op1.punch + op2.punch); }

 

+= 연산자 중복

두 개의 Power 객체에 대한 += 연산도 구현해보았습니다. kick과 punch 멤버를 각각 += 한 객체를 반환합니다. 이 때 외부함수로 선언하기 때문에 두 개의 객체를 대상으로 받아, 왼쪽의 객체를 결과로 반환합니다. a+=b 라는 연산을 하면 이와 같은 연산은 a = a + b 이기 때문에 a를 반환한다는 것입니다.

// 전위 ++ 연산자 (멤버 함수)
#include <iostream>
using namespace std;

class Power {
	int kick;
	int punch;
public:
	Power(int k = 0, int p = 0) : kick(k), punch(p) {};
	void show() { cout << "kick = " << kick << ',' << "punch = " << punch << endl; }
	Power operator++ ();
};

Power Power::operator++() {
	kick++; punch++;
	return *this; // 변경된 객체 자기 자신 리턴
}

int main() {
	Power a(3, 5), b;
	a.show();
	b.show();
	b = ++a;
	// b = a++; 컴파일 오류
	a.show();
	b.show();
}

 

그런데 위 같은 연산을 멤버 함수로 구현하려면 어떻게 해야할까요? 여기서는 this 포인터를 응용해야 합니다.

class Power {
	int kick;
	int punch;
public:
	Power(int k = 0, int p = 0) : kick(k), punch(p) {};
	void show() { cout << "kick = " << kick << ',' << "punch = " << punch << endl; }

	// + 연산자 중복 멤버 함수 선언
	Power operator += (Power op);
};

Power Power::operator += (Power op) {
	this->kick = this->kick + op.kick;
	this->punch = this->punch + op.punch;
	return *this; // 자기 자신 객체를 반환
}

멤버 함수로 선언되어 있으니 연산의 대상 중 왼쪽 항이 바로 객체 자기 자신이 되는 것입니다. 그래서 this 포인터를 통해 값을 더해주고, 자기 자신의 객체를 반환해야 하므로 *this 를 사용해 연산이 완료된 객체 자기 자신의 참조를 반환합니다. 이는 바로 아래에서 배울 단항 연산자의 경우, 많이 사용되는 응용이기 때문에 꼭 이해하고 넘어가야 합니다.

 

 

 

 

 단항 연산자 중복

 

단항 연산자는 전위 연산자와 후위 연산자로 나뉘어집니다. 그런데 단항 연산자는 전위나 후위나 둘 다 매개변수가 없기 때문에, 이 둘을 구분할 방법이 없습니다. 그래서 C++ 에서는 일종의 편법? 을 통해서 후위 연산자를 나타냅니다. 코드를 보면서 알아보도록 하겠습니다.

// 전위 ++ 연산자 (멤버 함수)
#include <iostream>
using namespace std;

class Power {
	int kick;
	int punch;
public:
	Power(int k = 0, int p = 0) : kick(k), punch(p) {};
	void show() { cout << "kick = " << kick << ',' << "punch = " << punch << endl; }
	Power operator++ ();
};

Power Power::operator++() {
	kick++; punch++;
	return *this; // 변경된 객체 자기 자신 리턴
}

int main() {
	Power a(3, 5), b;
	a.show();
	b.show();
	b = ++a;
	// b = a++; 컴파일 오류
	a.show();
	b.show();
}

단항 연산자를 멤버 함수로 구현하니 대상은 자기 자신이고, *this 를 사용해 자기 자신을 반환합니다. 그런데 이 ++ 연산자를 후위 연산자로 사용하면 컴파일 오류가 발생하게 됩니다. 위처럼 매개 변수에 아무 것도 없는 경우에는 컴파일러가 전위연산자로 인식하기 때문입니다. 만약 위 전위 ++ 연산자 코드를 외부함수로 구현하고 friend 키워드로 선언한다면

class Power {
	int kick;
	int punch;
public:
	Power(int k = 0, int p = 0) : kick(k), punch(p) {};
	void show() { cout << "kick = " << kick << ',' << "punch = " << punch << endl; }
	friend Power operator++ (Power& op);
};

Power operator++(Power& op) {
	op.kick++; op.punch++;
	return op; // 변경된 객체 자기 자신 리턴
}

이렇게 수정해주시면 됩니다. 여기서 아주아주 실수하기 좋은 부분이 있는데 바로 매개변수 부분입니다. 그냥 객체 Power 를 입력으로 받는 것이 아닌 "참조변수 Power&" 를 매개변수로 받습니다. 그렇지 않으면 함수 내에서 1을 증가시켜도 그게 원본 객체의 값을 변경시키는 것이 아니라 복사본의 값만 증가시키는 것이기 때문에 실제 값은 변하지 않습니다. 이에 대한 내용은 아래 포스팅에 자세히 나와있습니다.

 

[C++] 참조 매개 변수 / 참조 리턴 (Reference parameter / Reference return) + Call-by-reference

참조 매개 변수와 참조 리턴을 이해하기 위해서는 프로그래밍 언어의 인자 전달 방식(argument passing) 에 대해 복습해야합니다. https://blog.naver.com/songsite123/222938354059 두 변수의 값 서로 바꾸기(Call-by

songsite123.tistory.com

 

 

 

아무튼 후위 연산자는 그래서 어떻게 나타내냐? 바로 매개변수에 int 를 추가 하면 컴파일러가 후위 연산자로 인식합니다. 

Power operator++(); // 전위 ++ 연산자
Power operator++(int x); // 후위 ++ 연산자

이 때 int 는 실제로 사용하는 값이 아닙니다. 의미 없는 값이므로 무시해도 되지만, 컴파일러에게 전위와 후위를 구분할 수 있도록 만든 일종의 표기법입니다.

// 후위 ++ 연산자 (멤버 함수)
#include <iostream>
using namespace std;

class Power {
	int kick;
	int punch;
public:
	Power(int k = 0, int p = 0) : kick(k), punch(p) {};
	void show() { cout << "kick = " << kick << ',' << "punch = " << punch << endl; }
	Power operator++ (int x);
};

Power Power::operator++(int x) {
	Power temp = *this; // 증가 이전 객체 상태 저장
	kick++; punch++;
	return temp; // 변경된 객체 자기 자신 리턴
}

int main() {
	Power a(3, 5), b;
	a.show(); b.show();
	// b = ++a; 컴파일 오류
	b = a++;
	a.show(); b.show();
}

이렇게 int 를 추가하면 후위 연산자로 인식하기 때문에 오히려 전위 연산자로 사용하게 되면 컴파일 오류가 발생합니다. 

class Power {
	int kick;
	int punch;
public:
	Power(int k = 0, int p = 0) : kick(k), punch(p) {};
	void show() { cout << "kick = " << kick << ',' << "punch = " << punch << endl; }
	friend Power operator++ (Power& op, int x);
};

Power operator++(Power& op, int x) {
	Power temp = op;
	op.kick++; op.punch++;
	return temp; 
}

만약 외부함수로 구현하고 friend 로 선언하고 싶다면 위처럼 구현하면 동일한 출력 결과를 얻어낼 수 있습니다. 여기도 동일하게 매개변수로 참조 변수를 사용하는 것을 유의깊게 보셔야 하고 그 의미를 이해하셔야 합니다.

 

 

 

 << , >> 연산자 중복

 

cout << 객체; 라는 코드를 수행하면 당연히 출력되지 않습니다. 우리가 추가적으로 << 연산자 중복을 통해 이를 수행할 수 있고, 한 번에 객체로 cin >> 을 받아서 저장하는 것도 가능합니다.

 

iostream 은 istream 과 ostream 으로 나뉘고, cout 과 cin 은 각각 ostream 객체와 istream 객체입니다. 따라서 우리는 연산자 중복을 통해서 istream& 와 ostream& 참조 리턴을 해줘야 cin 과 cout 으로 입출력을 진행할 수 있습니다. 말로 하면 굉장히 복잡하기 때문에 예시 코드를 보도록 하겠습니다.

 

#include <iostream>
using namespace std;
class Complex {
private:
	double x, y;
public:
	friend istream& operator >> (istream& is, Complex& v);
	friend ostream& operator << (ostream& os, const Complex& v);
	Complex(double x = 0, double y = 0) : x(x), y(y) {}
	Complex operator+(const Complex& v2) const {
		Complex v(0.0, 0.0);
		v.x = this->x + v2.x;
		v.y = this->y + v2.y;
		return v;
	}
	void display() { cout << "(" << x << "," << y << "i" << ")" << endl; }
};

istream& operator >> (istream& is, Complex& v)
{
	is >> v.x >> v.y;
	return is;
}

ostream& operator<<(ostream& os, const Complex& v) {
	os << "(" << v.x << "," << v.y << "i" << ")" << endl;
	return os;
}

int main() {
	cout << "두 개의 복소수 값을 입력하라 : ";
	Complex v1, v2;
	cin >> v1 >> v2;
	Complex v3 = v1 + v2;
	cout << v1 << v2 << v3;
	return 0;
}

 

함수 구현 부분 원형 부분만 자세히 보겠습니다. 외부함수로 구현하고 friend 키워드로 가져왔죠.

friend istream& operator >> (istream& is, Complex& v);
friend ostream& operator << (ostream& os, const Complex& v);

원형을 깊게 들어가면 너무 딥한 내용이기 때문에 참조 리턴에 대한 개념만 다시 짚고 넘어가겠습니다. 아까 단항 연산자에서도 외부 함수로 구현하는 경우는 인자를 객체의 참조로 받았죠. call-by-value 로 받으면 값의 변경이 적용되지 않고 복사본 값만 바꾸기 때문에 의미 없는 작업이 되었기 때문에 그렇습니다. << 연산자와 >> 연산자에 대해서도 cin 과 cout 자체가 함수가 아니라 객체이기 때문에 객체의 참조를 받아서 이것저것 수행 한 이후, 다시 참조를 리턴해야 변경한 내용이 제대로 적용됩니다.

 

 

 

 

 

 

참고문헌 : 명품 C++ Programming