상세 컨텐츠

본문 제목

C++ 다양한 연산자 오버로딩의 예시

C++/C++98

by deulee 2023. 8. 5. 17:44

본문

각 연산자별로 주의해야할 점들이 많고 고유하게 적용되는 규칙들이 있다.

 

이번 글에서는 하나하나 알아보도록 하자.

 

1. 관계 연산자

관계 연산자는 동일한 타입의 두 객체에 대해 상등 및 대소를 비교하는 연산자이다.

 

다음 예시를 보도록 하자.

#include <iostream>

class Num
{
private:
	int x;
public:
	Num() {}
	Num(int _x) : x(_x)
	{}
	bool operator==(const Num& right) const // ==
	{
		return x == right;
	}
	bool operator!=(const Num& right) const // !=
	{
		return !(*this == right);
	}
	bool operator>(const Num& right) const // >
	{
		return x > right;
	}
	bool operator>=(const Num& right) const // >=
	{
		return (*this == right || *this > right);
	}
	bool operator<(const Num& right) const // <
	{
		return !(*this >= right);
	}
	bool operator<=(const Num& right) const // <=
	{
		return !(*this > right);
	}
};

 

2. 증감 연산자

 

증감 연산자는 값을 1 증가시키는데 이게 전위•후위 증감자에 따라 표현법이 조금 달라진다.

 

우선 코드부터 보도록 하겠다.

class Num
{
private:
	int x;
public:
	Num() {};
	Num(int x) : (_x)
	{}
	Num& operator++() // 전위
	{
		x++;
		return *this;
	}
	const Num operator++(int dummy) // 후위
	{
		Num ret = *this;
		++(*this);
		return ret;
	}
};

 

결론부터 말하자면 전위 증감 연산자와 후위 증감 연산자를 이름이나 인수 목록으로 구분 할 수 없다.

 

둘 다 A.operator++() 형태를 띄우고 있는데 이를 구분하기 위해서 C++은 더미 인수를 쓰는 방법을 쓰기로 결정했다.

 

전위는 증가되는 객체 외에는 인수를 취하지 않으면 후위형은 연산 대상인 객체 외에도 정수형의 더미 인수를 하나 더 추가하여 오버로딩을 성립하기로 한 것이다.

 

이를 통해 컴파일러가 둘을 구분할 수 있게 된 것이다.

 

이건 일종의 약속이라 어쩔 수 없다.

 

또 주목해야 할 점은 둘의 리턴값이 다르다는 것이다.

 

전위형Num&를 리턴하고 후위형const Num을 리턴한다.

 

1. 전위형 - Num&

2. 후위형 - const Num

 

우선 전위형은 ++++A같은 식이 성립되어야 하기 때문이다. 연속적으로 실행되어야 하기 때문에 참조형으로 반환해야 한다.

 

만약 Num으로 반환한다면 첫 번째 ++에는 A가 증가할테지만 두 번째 ++에는 첫 번째의 *this의 사본을 증가 시키게 되며 최종적으로 A는 한번만 증가하게 될 것이다.

 

후위형의 경우는 값을 먼저 평가한 후 증가해야 하므로 객체 자체를 리턴할 수 없으며 값만 리턴할 수 있다..

 

또한 리턴된 임시 객체를 변경시키는 것은 의미가 없으며 A++++은 금지되어야 한다.

 

그러므로 아예 시도를 하지 못하게 막아버리는 것이다.

 

가급적이면 전위형의 연산자를 사용하는 것이 효율적이므로 더 유리하다.

 

3. 대입 연산자

 

대입 연산자는 초창기에도 한번 언급한 적이 있다.

 

우선 코드는 다음과 같다.

class Num {
	private:
		int x;
	public:
		Num()
		{}
		Num(int _x) : x(_x)
		{}
		Num& operator=(const Num& right)
		{
			this->x = right.x;
			return *this;
		}
		Num& operator+=(const Num& right)
		{
			this->x += right.x;
			return *this;
		}
};

 

우선 리턴 값이 참조형인 이유는 리턴 후에도 연속적으로 대입이 되어야 하기 때문이다.

 

A = B = C;

 

그리고 함수가 상수형이 아닌 이유는 객체 자체가 값이 바뀌기 때문이다.

 

그리고 주의해야할 점이 있는데 이것은 이 글을 통해서 보도록 하자.

 

4. <<  연산자

 

C++의 표준 스트림 출력 객체인 cout은 << 연산자를 오버로딩하여 이 연산자의 우변을 표준 출력으로 내보내는 기능을 한다.

 

이것은 자료형을 따지지 않고 대부분 출력되는데 그 이유는 cout 객체의 소속 클래스인 ostream에 이미 << 멤버 연산자 함수가 오버로딩되어 있기 때문이다.

 

그럼 객체는 어떻게 출력할 수있을까?

 

class Num 
{
	friend ostream& operator<<(ostream& c, const Num& N);
	friend ostream& operator<<(ostream& c, const Num* pN);
private:
	int x;
public:
	Num() {}
	Num(int _x) : x(_x)
	{}
	void OutNum()
	{
		std::cout << x;
	}
};

ostream& operator<<(ostream& c, const Num& N)
{
	c << N.x;
	return c;
}

ostream& operator<<(ostream& c, const Num* pN)
{
	c << *pN;
	return c;
}

 

우선 생각할 수 있는 방법은 두 가지다.

 

1. ostream 클래스에 멤버 함수를 추가하는 것

 

2. 전역 함수로 추가하는 것

 

결론부터 말하자면 1번은 안된다. 왜냐하면 ostream 클래스는 이미 기본 자료형의 연산자처럼 컴파일러 단위에서 수정하는걸 막았기 때문이다.

 

그럼 전역 함수로 추가해야 하는 건데 그 방법은 위와 같다. 이 전에 전역 연산자 함수를 추가하는 것과 똑같은 방법이다.

 

레퍼런스로 리턴되는 이유는 이 전과 마찬가지로 연속적으로 연산을 해야 하는 상황이 있기 때문이다.

 

cout << x << y << z;

 

5. [ ] 연산자

 

[] 연산자는 배열에서 첨자 번호로부터 요소를 찾는 연산자이다.

 

이는 반드시 멤버 함수로만 정의할 수 있으며 전역 함수로는 정의할 수 없다.

 

우선 코드를 먼저 보도록 하자.

 

class NArr
{
private:
	int arr[3];
public:
	NArr()
	{
		arr[0] = 1;
		arr[1] = 5;
		arr[2] = 10;
	}
	int& operator[](int index)
	{
		return arr[index];
	}
	const int& operator[](int index) const
	{
		return arr[index];
	}
};

우선 똑같은 내용이 두 번 정의 된 것을 볼 수 있다.

 

1. 비상수

 

2. 상수

 

우선 배열의 동작은 대표적으로 읽고 쓰기가 가능해야 한다.

 

그렇기 때문에 비상수 객체에 대해서는 첫 번째 정의가 되지만 만약 상수 객체는 어떻게 하는가?

 

상수 객체는 읽기만 가능할 뿐 쓰기가 허용되어선 안된다.

 

하지만 비상수 버전만 정의해 놓는다면 상수 객체는 함수의 상수성이 지정되어 있지 않으면 일반 멤버 함수를 호출할 수 없다.

 

그렇기 때문에 상수 객체를 위해서 하나 더 정의한다고 생각하면 된다.

 

하지만 이것 하나만을 위해서 새로 정의하는건 불편하지 않을까?

 

사실 한가지 더 이유가 있다.

void func(const Narr *ptr) {...}

이렇게 포인터로 넘겼을 때 해당 포인터가 가리키고 있는 객체에 대한 수정을 불가능하게 하였을 경우 상수 객체를 위한 오버로딩도 따로 만들어줘야 하는 것이다.

 

그럼 만약 arr[0][1] = 1; 이런 식으로 호출하면 어떻게 될까?

 

하지만 이는 쉽게 구분할 수 있다. 앞쪽의 [0]는 전체 배열 arr에 대해 쓰여졌으므로 이 연산자는 본래의 배열 첨자 연산자이며 arr 배열에서 0번째 요소를 선택하고, 뒤쪽의 [1]은 arr[0]에 대해 쓰여졌으며 arr[0]은 NArr 타입의 객체이므로 이 연산자는 오버로딩된 연산자를 호출한다.

 

6. 멤버 참조 연산자

클래스나 구조체의 멤버를 참조하는 연산자에는 . 과 -> 두 가지가 있다.

 

이중 . 연산자는 기본적인 연산자라 오버로딩이 불가능하다.

 

그럼 -> 연산자는 가능한데 여기에 조금 특별한 규칙이 적용된다.

 

원래 이항 연산자인데 마치 오버로딩하면 단항 연산자가 되며 전역 함수로는 정의할 수 없고 클래스의 멤버 함수로만 정의할 수 있다.

 

멤버 함수이면서 단항이므로 결국 인수를 취하지 않는다.

 

이 연산자의 리턴 타입은 클래스나 구조체의 포인터고정되어 있다.

 

이를 사용하게 되면 객체의 멤버 객체를 마치 자기 자신처럼 사용할 수 있다.

 

#include <iostream>
#include <cstring>

struct Person
{
	char Name[32];
	int Age;
};

class Book
{
private:
	Person Writer;
public:
	Book(const char* _Name, int _age)
	{
		strcpy(Writer.Name, _Name);
		Writer.Age = _age;
	}
	Person* operator->()
	{
		return &Writer;
	}
};

int main(void)
{
	Book bkr("jason", 24);

	std::cout << bkr->Name << ' ' << bkr->Age << std::endl;
	return 0;
}

 

-> 연산자 오버로딩의 본체를 보면 분명 멤버 객체의 주소를 반환하고 끝나는 것처럼 보이는데 어떻게 출력물은 Writer의 내부 멤버들의 값을 불러들일 수 있을까?

 

사실 bkr->Name 표현식은 컴파일러에 의해 다음과 같이 해석된다.

 

bkr->()->Name;

 

즉, -> 연산자는 숨겨진 멤버의 포인터를 읽어 줌과 동시에 이 멤버에 속한 멤버를 바로 액세스 할 수 있도록 중계하는 역할을 한다.

 

7. () 연산자

 

()도 함수를 호출하는 일종의 연산자다. 두둥

 

다른 연산자와 달리 매개 변수의 개수가 정해져 있지 않다는 것이 특징이다. 이항일수도 있고 단항일 수도 있고 세 개 이상의 인수를 요구할 수도 있다.

#include <iostream>

class Sum
{
public:
	int operator()(int a, int b, int c, int d)
	{
		return a + b + c + d;
	}
};

int main(void)
{
	Sum S;
	std::cout << S(1, 2, 3, 4) << std::endl;
	return 0;
}

 

호출부를 다르게 표현하면 다음과 같다.

 

S()(1, 2, 3, 4);

 

매개 변수의 개수가 다양하게 쓸 수 있다는 것은 굉장히 큰 장점이 되며 () 연산자 오버로딩 한 객체를 함수 객체(Functor)라고 한다.

 

출처

http://www.soen.kr/lecture/ccpp/cpplec.htm

 

C/C++ 강좌

 

www.soen.kr

 

'C++ > C++98' 카테고리의 다른 글

C++ 오버라이딩  (0) 2023.08.07
C++ 상속  (0) 2023.08.07
C++ 전역 연산자 함수  (0) 2023.08.05
C++ 연산자 함수  (0) 2023.08.05
C++ 상수 멤버, const  (0) 2023.08.05

관련글 더보기