본문 바로가기
프로그래밍/C & C++

[C++] 연산자 오버로딩 (1)

by 별준 2022. 2. 20.

References

  • Professional C++

Contents

  • 연산자 오버로딩 (Operator Overloading)
  • 산술 연산자 오버로딩
  • 비트 연산자 / 논리 연산자 오버로딩
  • 스트림 입출력 연산자 오버로딩
  • 인덱스 연산자 오버로딩

C++에서는  +,-,= 과 같은 연산자의 의미를 클래스에서 새롭게 정의할 수 있습니다. 연산자 오버로딩을 활용하면 클래스를 int나 double 같은 기본 타입으로 취급할 수 있습니다. 심지어 클래스를 배열이나 함수, 포인터처럼 만들 수도 있습니다.

 

아마 오버로딩에 관해서는 잘 알고 있을거라고 생각하지만, 놓치는 부분들이 있을 수도 있는 연산자 오버로딩에 대해 세부사항들을 소개해보도록 하겠습니다.

 


1. 연산자 오버로딩 개요

C++은 +, <, *, << 와 같은 기호 형태의 연산자를 제공합니다. 이러한 연산자는 int나 double 같은 내장된 태압에 대해 산술 연산이나 논리 연산을 비롯한 다양한 연산을 수행합니다. 또한 ->나 *처럼 포인터를 역참조하기도 합니다. C++에서 말하는 연산자는 그 의미가 상당히 광범위합니다. 배열 인덱스인 []와 함수 호출을 나타내는 (), 캐스팅, 메모리 할당 및 해제 등도 일종의 연산자입니다. 연산자 오버로딩을 활용하면 언어에서 기본으로 제공하는 연산자의 동작을 자신이 정의하는 클래스에 맞게 변경할 수 있습니다. 하지만 이때 정해진 규칙과 한계를 벗어날 수는 없고 몇 가지 선택해야 것들도 있습니다.

 

1.1 연산자 오버로딩을 하는 이유

연산자 오버로딩을 하는 구체적인 이유는 연산자마다 다르지만 공통적으로 자신이 정의할 클래스를 기본 타입처럼 다루기 위해서 입니다. 정의한 클래스가 기본 타입에 가까울수록 클라이언트 입장에서는 사용하기가 쉽습니다. 예를 들어 분수를 표현하는 클래스에서 +, -, *, /를 기본 타입에 대한 연산자처럼 정의할 수 있습니다.

 

연산자 오버로딩을 하는 또 다른 이유는 프로그램을 좀 더 세밀하게 제어하기 위해서입니다. 예를 들어 직접 정의한 클래스의 객체를 새로 만들어서 분배하고 수거하는 과정을 원하는 방식으로 정의할 때 메모리 할당과 해제 연산자를 오버로딩할 수 있습니다.

 

이때 연산자 오버로딩의 혜택을 받을 대상은 클래스 작성자보다는 클래스 사용자입니다.

 

1.2 연산자 오버로딩의 한계

아래와 같은 것들은 연산자 오버로딩할 수 없습니다.

  • 연산자 기호를 새로 만들 수는 없습니다. 언어에서 정의되어 있는 연산자만 그 동작을 변경할 수 있습니다.
  • '::', sizeof, '? :'(조건 연산자)를 비롯한 일부 연산자는 오버로딩할 수 없습니다. 보통 오버로딩할 수 없는 연산자는 대부분 오버로딩할 일이 없는 것이라 단점이 되지는 않습니다.
  • arity는 연산자의 인수 또는 피연산자(operand)의 개수입니다. arity를 변경할 수 있는 곳은 함수 호출, new, delete 연산자뿐 입니다. 그 외 나머지 연산자는 arity를 변경할 수 없습니다. ++와 같은 단항(unary) 연산자는 피연산자를 하나만 받습니다. /와 같은 이항(binary) 연산자는 피연산자를 두 개 받습니다. 이러한 제약사항은 배열 인덱스를 나타내는 [] 연산자를 오버로딩할 때 문제가 됩니다. 이에 대해서는 뒤에서 자세히 설명하겠습니다.
  • 연산자의 평가(evaluation) 순서를 결정하는 우선순위(precedence)와 결합순위(associativity)는 바꿀 수 없습니다.
  • 기본 타입의 연산자는 재정의할 수 없습니다. 오버로딩할 수 있는 연산자는 클래스의 메소드이거나 오버로딩하려는 전역 함수의 인수 중 최소 하나가 사용자 정의 타입(ex, 클래스)이어야 합니다. 다시 말해 int 타입의 +가 뺄셈이 되도록 변경할 수 없습니다. 단 메모리 할당과 해제 연산자는 예외입니다. 프로그램에 나온 모든 메모리 할당에 대한 전역 연산자를 모두 변경할 수 있습니다.

 

기본 연산자 중에는 두 가지 의미를 가진 것도 있습니다. 예를 들어 - 연산자는 (x = y - z;) 처럼 이항 연산자로 사용할 수도 있고, (x = -y;)처럼 단항 연산자로 사용할 수도 있습니다. * 연산자도 곱셈과 포인터 역참조라는 두 가지 역할을 합니다. << 연산자도 문맥에 따라 스트림 추가 연산을 의미하거나 왼쪽 쉬프트 연산을 의미합니다.

 

1.3 연산자 오버로딩 선택

연산자 오버로딩은 operatorX라는 이름의 메소드나 함수를 정의하는 방식으로 정의합니다. 여기서 X 자리에 오버로딩할 연산자 기호를 적습니다. operator와 X 사이에 공백을 넣어도 되며, 예를 들어 SpreadsheetCell 이라는 클래스를 가지고 operator+ 연산자를 선언하려면 다음과 같이 작성할 수 있습니다.

SpreadsheetCell operator+(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs);

 

연산자 오버로딩하는 함수나 메소드를 작성할 때 몇 가지 선택해야할 사항들이 있는데 하나씩 살펴보도록 하겠습니다.

 

1.3.1 Method or Global Function

가장 먼저 오버로딩할 연산자를 클래스의 메소드로 정의할 지, 전역 함수(주로 클래스의 friend)로 정의할 지 선택해야 합니다. 그러기 위해서는 먼저 두 방식의 차이점부터 알아야 합니다. 연산자가 클래스의 메소드일 때는 연산자를 사용할 표현식의 좌변은 반드시 그 클래스의 객체이어야 합니다. 반면 전역 함수로 정의하면 좌변에 다른 타입의 객체를 적을 수 있습니다.

 

연산자는 다음과 같이 크게 세 종류가 있습니다.

  • 반드시 메소드로 정의해야 하는 연산자 : 어떤 연산자는 클래스 문맥을 벗어나면 의미가 없기 때문에 반드시 어떤 클래스에 속한 메소드여야 합니다. 예를 들어 operand=은 속한 클래스와 밀접한 관계가 있어서 클래스와 별도로 존재할 수 없습니다. 아래에서 오버로딩할 수 있는 연산자에 대해서 살펴볼텐데, 이를 보면 어떤 연산자가 반드시 메소드로 정의해야 하는지 확인할 수 있습니다. 나머지 연산자들은 이런 제약이 없습니다.
  • 반드시 전역 함수로 정의해야 하는 연산자 : 연산자의 좌변에 소속 클래스가 아닌 다른 타입의 변수도 나와야 한다면 연산자를 전역 함수로 만들어야 합니다. 대표적인 예로 좌변에 연산자가 속한 클래스 객체가 아닌 iostream 객체가 나와야 하는 operator<<와 operator>>가 있습니다. 또한 이항 연산자인 +나 -처럼 교환 법칙을 따르는 연산자는 반드시 좌변에 연산자가 속한 클래스가 아닌 타입의 변수가 나와야 합니다.
  • 메소드와 전역 함수 둘 다 가능한 연산자 : 사실 연산자를 오버로딩할 때 메소드로 만들어야 할지 아니면 전역 함수로 만드는 것이 나은지에 대한 의견이 분분합니다. 저자의 원칙은 방금 설명한 것처럼 특별히 전역 함수로 만들어야 할 이유가 없다면 무조건 메소드로 만드는 것이 좋다고 생각합니다. 이 원칙이 좋은 이유는 메소드로 만들면 virtual로 선언할 수 있지만 전역 함수는 그럴 수 없기 때문입니다. 따라서 여러 연산자를 같은 상속 계층에 속하도록 오버로딩하려면 메소드로 만드는 것이 좋습니다.

메소드로 오버로딩한 연산자가 객체를 변경하지 않는다면 반드시 const로 선언해야 합니다. 그래야 const 객체에 대해서도 그 연산자를 호출할 수 있습니다.

 

1.3.2 Choosing Argument Types

인자 타입에 대해서는 선택할 사항이 많지 않는데, 사실 인자의 개수를 변경할 일이 별로 없기 때문입니다. 예를 들어 operator/를 전역 함수로 만들 때는 인수가 반드시 두 개여야 하고, 메소드로 만들면 인수를 한 개만 받을 수 있습니다. 일반 함수를 오버로딩할 때는 매개변수 개수에 제약이 없습니다. 또한 오버로딩할 연산자의 타입을 마음껏 정할 수 있지만, 실제로는 연산자가 속한 클래스에 따라 그 범위가 제한됩니다. 예를 들어 T라는 클래스에 덧셈을 구현할 때 operator+가 string을 두 개 받도록 정의하지는 않을 것입니다. 그보다는 매개변수를 값으로 받을지 레퍼런스로 받을 지, const로 받을지 아닌지 결정하는 것이 더 중요합니다.

 

값으로 받을지 레퍼런스로 받을지 결정하는 것은 쉽습니다. non-primitive 타입의 매개변수는 모두 레퍼런스로 받도록 하면 됩니다. 객체를 레퍼런스로 전달할 수 있는데도 값으로 전달하는 것은 바람직하지 않습니다.

 

const 지정 여부도 쉽게 결정할 수 있습니다. 변경할 일이 없다면 무조건 const로 지정합니다.

 

1.3.3 Choosing Return Types

C++은 적합한 오버로딩 연산자를 찾을 때 리턴 타입을 고려하지 않습니다. 이 때문에 연산자를 오버로딩할 때 리턴 타입을 마음껏 지정할 수 있습니다. 하지만 이렇게 허용하더라도 반드시 그렇게 해야하는 것은 아닙니다. 비교 연산자가 포인터를 리턴하거나, 산술 연산자가 bool 타입을 리턴하는 것처럼 원하는 대로 정의할 수는 있지만 바람직하지는 않습니다. 그보다는 오버로딩한 연산자도 기본 타입 연산자와 동일한 타입을 리턴해야 합니다.

예를 들어 비교 연산자의 리턴 타입을 bool로 정의하고, 산술 연산자는 결과를 담는 객체를 리턴하게 정의합니다. 간혹 리턴 타입을 확실히 정하기 힘들 때가 있습니다. 예를 들어 operator=이 중첩된 대입을 지원하려면 반드시 이 연산자를 호출한 객체의 레퍼런스를 리턴해야 합니다. 이렇게 리턴 타입이 불분명한 다른 연산자에 대해서는 아래에서 다룰 오버로딩할 수 있는 연산자에 대한 표(1.5 참조)를 참조하시길 바랍니다.

 

레퍼런스와 const에 대한 결정 원칙은 리턴 타입에도 똑같이 적용됩니다. 그런데 값을 리턴할 때는 결정하기가 까다롭습니다. 일반적으로 레퍼런스로 리턴할 수 있으면 레퍼런스로 리턴하고, 그렇지 않으면 값으로 리턴하는 원칙을 적용합니다. 그렇다면 레퍼런스로 리턴할 수 있다는 것은 어떻게 알 수 있을까요? 이 문제는 객체를 리턴하는 연산자에서만 발생합니다. bool을 리턴하는 비교 연산자, 리턴값이 없는 변환 연산자, 아무 타입이나 리턴할 수 있는 함수 호출 연산자는 이런 고민을 할 필요가 없습니다. 연산자에서 객체를 새로 생성한다면 반드시 값으로 리턴합니다. 그렇지 않으면 연산자를 호출한 객체나 연산자의 인수에 대한 레퍼런스로 리턴합니다.

 

좌측값(lvalue)으로 사용해서 변경될 가능성이 있는 리턴값은 const로 지정하지 않습니다. 나머지 경우는 const로 지정합니다. operator=, operator+=, operator-=과 같은 대입 연산자 외에도 좌측값을 리턴하는 연산자가 생각보다 많이 있습니다.

 

1.3.4 Choosing Behavior

연산자를 오버로딩할 때 원하는 동작을 마음껏 구현할 수 있습니다. 예를 들어 operator+ 연산자로 다른 프로그램을 구동하도록 오버로딩할 수 있습니다. 그러나 왠만하면 클라이언트의 기대에 어긋나지 않게 구현해야 합니다. operator+를 오버로딩할 때는 덧셈 성격의 동작을 수행해야 합니다.

 

1.4 오버로딩하면 안되는 연산자

C++에서 허용하더라도 오버로딩하면 안되는 연산자가 있습니다. 그중에서도 특히 주소 연산자(operator&)는 오버로딩해서 좋은 점이 없을 뿐만 아니라 변수의 주소를 가져온다는 C++의 기본 동작을 상식과 다르게 변경해버려 오히려 헷갈리게 됩니다. 연산자 오버로딩을 상당히 많이 사용하는 표준 라이브러리도 주소 연산자만큼은 오버로딩하지 않습니다.

 

이항 부울 연산자인 operator&&과 operator||도 오버로딩하면 안됩니다. C++의 short-circuit evaluation rule을 적용할 수 없기 때문입니다.

 

마지막으로 콤마 연산자(opreator,)도 오버로딩하면 안됩니다. 처음 보셧을 수도 있는데 실제로 콤마 연산자라른 것이 C++에 정의되어 있습니다. 콤파 연산자를 순차 연산자(sequencing operator)라고 부르기도 하며, 한 문장에 나온 두 표현식을 분리하며 왼쪽에서 오른쪽 순으로 평가됩니다.

int x{ 1 };
cout << (++x, 2 * x); // Sets x to 2, and prints 4

 

1.5 오버로딩할 수 있는 연산자

다음 표는 C++에서 오버로딩을 허용하는 연산자를 종류별로 정리한 것입니다. 이 표를 보면 연산자마다 클래스의 메소드와 전역 함수 중 어떤 형태로 만들어야 할지, 그리고 오버로딩할 때의 제약사항과 리턴값의 형태를 포함한 프로토타입의 예시가 나와있습니다.

여기서 오버로딩할 연산자가 속한 클래스의 이름을 T로 표현하고, 이 클래스가 아닌 다른 타입은 E로 표현합니다. 여기서 보여주는 프로토타입은 예시일 뿐 연산자마다 T와 E를 얼마든지 다양하게 조합해서 선언할 수 있습니다.

 

 

1.6 우측값 레퍼런스

[C++] 클래스(Class) 심화편 (1)

 

[C++] 클래스(Class) 심화편 (1)

References Professional C++ https://en.cppreference.com/w/ Contents friend 객체 동적 할당 이동 생성자, 이동 대입 연산자 우측값 레퍼런스, Move Semantics 구현 std::exchange Rule of Zero [C++] 클래스(C..

junstar92.tistory.com

이전 포스팅에서 우측값 레퍼런스(rvalue reference)에 대해서 다루었는데, 좌측값 레퍼런스에서 쓰는 & 대신 &&을 쓴다고 언급했었습니다. 이동 대입 연산자(move assignment operator)를 구현할 때 사용했으며, 두 번째 객체가 대입 연산 후 삭제될 임시 객체일 때 컴파일러에서 이렇게 처리합니다.

위의 표에서 일반 대입 연산자의 프로토타입은 다음과 같습니다.

T& operator=(const T&);

 

이동 대입 연산자의 프로토타입도 우측값 레퍼런스를 사용한다는 점을 제외하면 이와 같습니다. 이 연산자는 인수를 변경하므로 const 인수를 전달할 수 없습니다.

T& operator=(T&&);

 

위에서 살펴본 표에는 우측값 레퍼런스를 적용한 프로토타입의 예가 없습니다. 하지만 대부분 연산자는 기존 좌측값 레퍼런스를 사용하는 버전과 우측값 레퍼런스를 사용하는 버전이 함께 있어도 상관없습니다. 어느 버전이 적합한지는 클래스 구현마다 다릅니다.

예시로 불필요한 메모리 할당을 방지하는 operator+가 있을 수 있습니다. 예를 들어 표준 라이브러리에서 제공하는 std::string 클래스는 operator+를 다음과 같이 우측값 레퍼런스로 구현했습니다. (여기서는 간략히 표현)

std::string operator+(std::string&& lhs, std::string&& rhs);

이 연산자는 두 인수가 우측값 레퍼런스로 전달되었기 때문에 둘 중 하나에 대한 메모리를 재사용할 수 있습니다. 참고로 인수가 우측값 레퍼런스라는 말은 연산이 끝나면 삭제되는 임시 객체라는 뜻입니다. 이렇게 구현된 operator+는 두 인수의 크기와 용량에 따라 다음 두 가지 동작 중 하나를 수행합니다.

return std::move(lhs.append(rhs));

또는,

return std::move(rhs.insert(0, lhs));

실제로 std::string에서 제공하는 operator+의 오버로딩 버전들을 보면 좌측값 레퍼런스와 우측값 레퍼런스를 다양하게 조합하고 있습니다. 그중에서 인수로 string 두 개를 받는 operator+ 연산자를 보면 다음과 같습니다. (간략히 표현함)

std::string operator+(const std::string& lhs, const std::string& rhs);
std::string operator+(std::string&& lhs, const std::string& rhs);
std::string operator+(const std::string& lhs, std::string&& rhs);
std::string operator+(std::string&& lhs, std::string&& rhs);

 

 

1.7 우선순위와 결합순서

연산자에는 우선순위(precedence)와 결합순서(associativity)가 있습니다. 아래의 표는 cpp refernce에서 제공하고 있는 표이며, 연산자들 간의 우선순위와 결합순서를 보여주고 있습니다.

https://en.cppreference.com/w/cpp/language/operator_precedence

 

1.8 관계 연산자

C++ 표준 라이브러리의 <utility> 헤더에서는 관계 연산자(relational operator)를 위한 다양한 함수 템플릿을 std::rel_ops 네임스페이스에서 제공합니다.

template<class T> bool operator!=(const T& a, const T& b); // Needs operator==
template<class T> bool operator>(const T& a, const T& b);  // Needs operator<
template<class T> bool operator<=(const T& a, const T& b); // Needs operator<
template<class T> bool operator>=(const T& a, const T& b); // Needs operator<

이러한 함수 템플릿은 다른 클래스에 있는 ==, < 연산자로 !=, >, <=, >=와 같은 연산자를 정의합니다. 예를 들어 operator==와 operator<를 구현할 때 이 템플릿에 나온 다른 관계 연산자를 그대로 가져다 쓸 수 있습니다. 

코드에서 #include <utility>와 다음과 같이 using 문만 추가하면 이 템플릿을 곧바로 사용할 수 있습니다.

using namespace std::rel_ops;

 

하지만 ,현재 정의한 클래스뿐만 아니라 관계 연산에 관련된 모든 클레스에 대해 이 연산자가 생성된다는 문제가 있습니다. 또한 이렇게 자동 생성된 관계 연산자는 std::greater<T>와 같은 유틸리티 템플릿과 함께 동작하지 않습니다. 마지막으로 암묵적 변환도 적용되지 않습니다.

 

따라서, 작성할 클래스에서 std::rel_ops를 그대로 사용하지 말고, 관계 연산자를 모두 구현하는 것이 좋습니다.

 


2. 산술 연산자 오버로딩

이항 산술 연산자와 축약 이항 산술 대입 연산자는 아마 잘 아실거라 생각됩니다. 그렇지 않다면 아래 포스팅에서 이에 대한 내용이 있으니 참고하셔도 좋을 듯 합니다 !

[C++] 클래스(Class) 심화편 (3)

 

[C++] 클래스(Class) 심화편 (3)

References Professional C++ https://en.cppreference.com/w/ Contents 연산자 오버로딩 Pimpl Idiom or Bridge Pattern 클래스 심화편 세 번째 포스팅입니다 ! [C++] 클래스(Class) 기본편 [C++] 클래스(Class)..

junstar92.tistory.com

 

여기서 예제로 사용할 클래스는 SpreadsheetCell 클래스이며, 이 클래스의 정의는 다음과 같습니다.

class SpreadsheetCell
{
public:
	SpreadsheetCell() = default;
	SpreadsheetCell(double initialValue);
	explicit SpreadsheetCell(std::string_view initialValue);

	void set(double value);
	void set(std::string_view value);

	inline double getValue() const { return m_value; }
	inline std::string getString() const { return doubleToString(m_value); }

	static std::string doubleToString(double value);
	static double stringToDouble(std::string_view value);
    
private:
	double m_value{ 0 };
};

위 정의에 대한 구현은 다음과 같습니다.

/*** SpreadsheetCell.cpp ***/
#include "SpreadsheetCell.h"
#include <stdexcept>

SpreadsheetCell::SpreadsheetCell(double initialValue)
	: m_value{ initialValue }
{
}

SpreadsheetCell::SpreadsheetCell(std::string_view initialValue)
	: m_value{ stringToDouble(initialValue) }
{
}

void SpreadsheetCell::set(double value)
{
	m_value = value;
}

void SpreadsheetCell::set(std::string_view value)
{
	m_value = stringToDouble(value);
}

std::string SpreadsheetCell::doubleToString(double value)
{
	return std::to_string(value);
}

double SpreadsheetCell::stringToDouble(std::string_view value)
{
	return std::stold(value.data());
}

 

2.1 단항 뺄셈, 단항 덧셈 연산자 오버로딩

C++은 다양한 단항(Unary) 산술 연산자를 제공합니다. 그중에서 뺄셈과 덧셈에 대한 단항 연산자를 살펴보겠습니다.

int에 적용한 예시는 다음과 같습니다.

int i, j{ 4 };
i = -j; // Unary minus
i = +j; // Unary plus
i = +(-j); // Apply unary plus to the result of applying unary minus to i
i = -(-j); // Apply unary minus to the result of applying unary minus to i

단항 뺄셈 연산자는 피연산자의 부포를 반대로 바꾸는 반면 단항 덧셈 연산자는 피연산자를 그대로 리턴합니다. 여기서 주목할 점은 단항 덧셈 또는 단항 뺄셈 연산자를 적용한 결과에 다시 단항 덧셈이나 뺄셈 연산을 적용할 수 있다는 것입니다. 단항 연산자는 객체를 변경하지 않기 때문에 const로 선언해야 합니다.

 

단항 operator- 연산자를 SpreadsheetCell 클래스의 멤버 함수로 정의한 예는 다음과 같습니다. 단항 덧셈은 대체로 항등 연산(identity operation)을 수행합니다. 따라서 단항 덧셈에 대해서는 오버로딩하지 않습니다.

SpreadsheetCell SpreadsheetCell::operator-() const
{
    return SpreadsheetCell{ -getValue() };
}

operator-는 피연산자를 변경하지 않기 때문에 음수를 갖도록 SpreadsheetCell 객체를 새로 만들어 리턴해야 합니다. 다시 말해서 레퍼런스로 리턴할 수 없습니다. 이렇게 오버로딩한 연산자의 사용법은 다음과 같습니다.

SpreadsheetCell c1(4);
SpreadsheetCell c3 = -c1;

 

2.2 증가/감소 연산자 오버로딩

변수를 1만큼 증가시키는 방법은 다음과 같이 4가지 방법이 있습니다.

i = i + 1;
i += 1;
++i;
i++;

이 중에서 마지막 두 문장에 나온 연산자를 증가 연산자(increment operator)라고 합니다. 세 번째 나온 연산자는 선행 증가(prefix increment) 연산자입니다. 표현식에 선행 증가 연산자를 적용한 변수가 나오면 변수에 1을 더한 결과를 표현식에서 사용합니다. 네 번째 문장에 나온 연산자는 후행 증가(postfix increment) 연산자입니다. 이 연산은 변수의 값을 증가하기 전에 연산자에 적용해서 리턴한 뒤 그 변수의 값을 증가시킵니다. 선행 감소(prefix decrement)와 후행 감소(postfix decrement) 연산자도 이와 같은 원리가 적용됩니다.

 

operator++과 operator--을 오버로딩할 때는 한 가지 문제가 발생합니다. 예를 들어 operator++를 오버로딩할 때 대상이 선행 증가인지 아니면 후행 증가인지 명확히 표현할 방법이 없습니다. 이를 위해 C++은 꼼수에 가까운 방법을 제공합니다. operator++이나 operator--의 선행 연산 버전은 인수를 받지 않고, 후행 연산 버전은 int타입의 인수를 하나만 받습니다.

 

SpreadsheetCell 클래스에서 operator++과 operator--를 오버로딩한 예는 다음과 같습니다.

SpreadsheetCell& operator++(); // Prefix
SpreadsheetCell operator++(int); // Postfix
SpreadsheetCell& operator--(); // Prefix
SpreadsheetCell operator--(int); // Postfix

선행 연산 버전의 리턴값은 피연산자의 최종 결과와 같습니다. 따라서 선행 증가 및 감소 연산의 호출 대상 객체는 레퍼런스로 리턴됩니다. 하지만 후행 증가 및 감소 연산의 리턴값은 피연산자의 최종 상태와 다르기 때문에 레퍼런스로 리턴할 수 없습니다.

 

operator++를 구현한 예를 살펴보겠습니다.

SpreadsheetCell& SpreadsheetCell::operator++()
{
    set(getValue() + 1);
    return *this;
}

SpreadsheetCell SpreadsheetCell::operator++(int)
{
    auto oldCell{ *this }; // Save current value
    ++(*this);             // Increment using prefix ++
    return oldCell;        // Return the old value
}

operator--의 구현도 이와 비슷합니다.

SpreadsheetCell& SpreadsheetCell::operator--()
{
    set(getValue() - 1);
    return *this;
}

SpreadsheetCell SpreadsheetCell::operator--(int)
{
    auto oldCell{ *this }; // Save current value
    --(*this);             // Decrement using prefix --
    return oldCell;        // Return the old value
}

 

이렇게 구현한 오버로딩 연산자는 다음과 같이 사용할 수 있습니다.

SpreadsheetCell c1{ 4 };
SpreadsheetCell c2{ 4 };
c1++;
++c2;

증가와 감소 연산자는 포인터에도 적용할 수 있습니다. 스마트 포인터나 반복자로 사용할 클래스를 작성할 때 operator++와 operator--를 오버로딩해서 포인터 증가와 감소 연산을 제공할 수 있습니다.

 


3. 비트 연산자와 논리 연산자 오버로딩

비트 연산자(bitwise operator)와 비트 축약 대입 연산자(bitwise shorthand assignment operator)는 산술 연산자와 산술 축약 대입 연산자와 비슷합니다. 하지만 실제 활용 사례가 극히 드물기 때문에 이에 관한 예제는 생략하도록 하겠습니다. 위에서 살펴본 오버로딩할 수 있는 연산자에 대한 표를 보면 이 연산자에 프로토타입이 나와있는데, 필요할 때 이 예만 봐도 충분히 오버로딩할 수 있을 것이라고 생각됩니다.

 

논리 연산자(logical operator)를 오버로딩하는 과정은 조금 복잡합니다. &&과 ||은 오버로딩하지 않는 것이 좋습니다. 이 연산자는 개별 타입에 적용되지 않고 부울 표현식의 결과를 취합하기만 합니다. 게다가 단락 평가 규칙(short-circuit evaluation)도 적용할 수 없습니다. 오버로딩한 &&이나 || 연산자의 매개변수에 바인딩하기 전에 좌변과 우변을 모두 평가해야 하기 때문입니다. 따라서 혹시라도 오버로딩해야 한다면 구체적인 타입에 대해 오버로딩해야 합니다.

 


4. 스트림 입출력 연산자 오버로딩

C++ 연산자는 산술 연산뿐만 아닐 스트림 입출력 연산에도 사용됩니다. 예를 들어 cout에 int나 string값을 쓸 때는 다음과 같이 추가(insertion) 연산자(<<)를 스트림 출력 연산자로 사용합니다.

int number{ 10 };
std::cout << "The number is " << number << std::endl;

스트림에서 데이터를 읽을 때는 다음과 같이 추출(extraction) 연산자(>>)를 스트림 입력 연산자로 사용합니다.

int number;
std::string str;
std::cin >> number >> str;

 

클래스를 정의할 때 스트림 입력과 출력 연산자를 오버로딩해서 다음과 같이 스트림 입출력을 표현할 수도 있습니다.

StreamsheetCell myCell, anotherCell, aThirdCell;
cin >> myCell >> anotherCell >> aThirdCell;
std::cout << myCell << " " << anotherCell << " " << aThirdCell << std::endl;

스트림 입력과 출력 연산자를 오버로딩하기 전에 먼저 자신이 정의할 클래스에서 스트림 입력과 출력을 처리하는 방법부터 정해야 합니다. 여기서는 SpreadsheetCell 에서 단순히 double 값을 읽고 쓰도록 정의했습니다.

 

스트림 입력이나 출력 연산자의 왼쪽에는 SpreadsheetCell 객체가 아닌 istream이나 ostream 객체(ex, cin or cout)가 나와야 합니다. istream이나 ostream 클래스에는 메소드를 직접 추가할 수 없기 때문에 스트림 입력과 출력 연산자를 전역 함수로 만들어서 오버로딩해야 합니다.

예를 들면 다음과 같습니다.

class SpreadsheetCell
{
    // .. 생략
};

std::ostream& operator<<(std::ostream& ostr, const SpreadsheetCell& cell);
std::istream& operator>>(std::istream& istr, SpreadsheetCell& cell);

스트림 출력 연산자에서 첫 번째 매개변수로 ostream에 대한 레퍼런스를 받도록 정의하면 파일 출력 스트림, 문자열 출력 스트림, cout, cerr, clog 등에 적용할 수 있습니다. 스트림에 대한 자세한 내용은 이전 포스팅([C++] I/O 스트림)을 참조해주세요. 스트림 입력 연산자도 이와 마찬가지로 istream에 대한 레퍼런스를 매개변수로 받으면 파일 입력 스트림, 문자열 입력 스트림, cin 등에 적용할 수 있습니다.

 

operator<<와 operator>>의 두 번째 매개변수는 스트림에 쓰거나 읽을 SpreadsheetCell 객체에 대한 레퍼런스입니다. 오버로딩한 스트림 출력 연산자는 SpreadsheetCell 객체를 변경하지 않기 때문에 레퍼런스를 const로 지정해도 됩니다. 하지만 스트림 입력 연산자는 SpreadsheetCell 객체를 수정하기 때문에 인수로 전달할 레퍼런스를 const로 지정할 수 없습니다.

 

두 연산자 모두 첫 번째 인수로 받은 스트림을 레퍼런스로 리턴합니다. 그래서 이 연산자를 중첩해서 사용할 수 있습니다. 한 가지 기억할 점은 이러한 연산자 구문은 실제로 전역 함수인 operator>>나 operator<<를 호출하는 구문의 축약형이라는 것입니다.

예를 들어 다음 문장을 살펴보겠습니다.

std::cin >> myCell >> anotherCell >> aThridCell;

이 문장은 실제로는 다음의 문장을 축약한 것입니다.

operator>>(operator>>(operator>>(std::cin, myCell), anotherCell), aThirdCell);

위 문장을 보면 첫 번째 operator>> 호출의 리턴값이 다음 호출의 입력으로 전달됩니다. 따라서 반드시 스트림을 레퍼런스로 리턴해야 다음 호출에 이어서 사용할 수 있습니다. 레퍼런스로 리턴하지 않는데 이렇게 중첩된 구문을 작성하면 컴파일 에러가 발생합니다.

 

SpreadsheetCell 클래스에서 operator<<와 operator>>는 다음과 같이 구현할 수 있습니다.

std::ostream& operator<<(std::ostream& ostr, const SpreadsheetCell& cell)
{
    ostr << cell.getValue();
    return ostr;
}

std::istream& operator>>(std::istream& istr, SpreadsheetCell& cell)
{
    double value;
    istr >> value;
    cell.set(value);
    return istr;
}

 


5. 인덱스 연산자 오버로딩

SpreadsheetCell 클래스가 아닌 표준 라이브러리의 vector나 array 같은 클래스 템플릿이 있다고 가정해보겠습니다. 그러면 동적 할당 배열에 대한 클래스를 직접 구현해야 합니다. 이 클래스는 특정한 인덱스에 있는 원소를 설정하거나 읽는 기능을 제공해야 합니다. 또한 내부적으로 메모리 할당 작업도 관리해야 합니다.

동적 할당 배열에 대한 클래스의 초기 버전을 다음과 같이 정의해보겠습니다.

template<typename T>
class Array
{
public:
    // Create an array with a default size that will grow as needed
    Array();
    virtual ~Array();

    // Disallow assignment and pass-by-value
    Array operator=(const Array& rhs) = delete;
    Array(const Array& src) = delete;

    // Move constructor and move assignment operator
    Array(Array&& src) noexcept;
    Array& operator=(Array&& rhs) noexcept;

    // Returns the value at index x.
    const T& getElementAt(size_t x) const;

    // Sets the value at index x.
    void setElementAt(size_t x, const T& value);

    // Returns the number of elements in the array;
    size_t getSize() const noexcept;
private:
    static const size_t AllocSize{ 4 };
    void resize(size_t newSize);
    T* m_elements{ nullptr };
    size_t m_size{ 0 };
};

이 코드를 살펴보면 원소를 설정하거나 가져오는 인터페이스를 정의했습니다. 따라서 랜덤 엑세스(random-access) 기능을 구현해야 합니다. 다시 말해 클라이언트가 배열을 생성한 뒤 원소를 설정할 때 메모리 관리 걱정없이 인덱스 순서와 무관하게 원하는 지점의 값을 설정할 수 있어야 합니다.

앞서 선언한 메소드는 다음과 같이 구현합니다.

template<typename T>
Array<T>::Array()
{
    m_size = AllocSize;
    m_elements = new T[m_size]{}; // Elements are zero-initialized
}

template<typename T>
Array<T>::~Array()
{
    delete[] m_elements;
    m_elements = nullptr;
}

template<typename T>
Array<T>::Array(Array&& src) noexcept
    : m_elements{ std::exchange(src.m_elements, nullptr) }
    , m_size{ std::exchange(src.m_size, 0) }
{
}

template<typename T>
Array<T>& Array<T>::operator=(Array<T>&& rhs) noexcept
{
    if (this == &rhs)
        return *this;
    delete[] m_elements;
    m_elements = std::exchange(rhs.m_elements, nullptr);
    m_size = std::exchange(rhs.m_size, 0);
    return *this;
}

template<typename T>
const T& Array<T>::getElementAt(size_t x) const
{
    if (x >= m_size) {
        throw std::out_of_range{ "" };
    }
    return m_elements[x];
}

template<typename T>
void Array<T>::setElementAt(size_t x, const T& val)
{
    if (x >= m_size) {
        // Allocate Allocsize past the element the client wants.
        resize(x + AllocSize);
    }
    m_elements[x] = val;
}

template<typename T>
size_t Array<T>::getSize() const noexcept
{
    return m_size;
}

template<typename T>
void Array<T>::resize(size_t newSize)
{
    // Create new bigger array with zero-initialized elements
    auto newArray{ std::make_unique<T[]>(newSize) };
    // The new size is always bigger than the old size (m_size)
    for (size_t i = 0; i < m_size; i++) {
        // Move the elements from the old array to the new one
        newArray[i] = std::move(m_elements[i]);
    }
    // Delete the old array, and set the new array
    delete[] m_elements;
    m_size = newSize;
    m_elements = newArray.release();
}

여기서 resize() 메소드를 예외에 안전하게 구현한 과정을 잠시 살펴보겠습니다. 우선 이 메소드는 적당한 크기로 배열을 새로 만들고 unique_ptr을 저장합니다. 그런 다음 이전 배열에 담긴 원소를 모두 새 배열로 복제합니다. 복제 과정에서 문제가 발생하면 unique_ptr에 의해 메모리가 자동으로 해제됩니다. 마지막으로 새 배열을 할당하는 작업과 기존 배열의 원소를 복제하는 작업이 모두 문제없이 끝날 때만, 즉, 예외가 발생하지 않을 때만 m_elements에 저장된 기존 배열을 삭제하고 새 배열을 여기에 대입합니다. 마지막 줄을 보면 새 배열에 대한 소유권을 unique_ptr로 부터 해제하도록 release()를 호출했습니다. 이렇게 하지 않으면 unique_ptr의 소멸자가 호출될 때 배열이 삭제됩니다.

 

이렇게 정의한 클래스를 사용하는 방법은 다음과 같습니다.

Array<int> myArray;
for (size_t i = 0; i < 10; i++) {
    myArray.setElementAt(i, 100);
}
for (size_t i = 0; i < 10; i++) {
    std::cout << myArray.getElementAt(i) << " ";
}

여기서 볼 수 있듯이 배열을 사용할 때 공간을 얼마나 차지할지 미리 정할 필요가 없습니다. 입력한 원소를 저장하는데 필요한 만큼 알아서 할당합니다. 그런데 매번 setElementAt()이나 getElementAt() 메소드를 호출하는 것은 꽤나 번거롭습니다. 따라서 다음과 같이 기존 배열 인덱스를 사용할 수 있으면 훨씬 편리할 것입니다.

Array<int> myArray;
for (size_t i = 0; i < 10; i++) {
    myArray[i] = 100;
}
for (size_t i = 0; i < 10; i++) {
    std::cout << myArray[i] << " ";
}

 

이럴 때는 인덱스 연산자를 오버로딩하면 됩니다. 따라서 다음과 같이 operator[]를 구현합니다.

template<typename T>
T& Array<T>::operator[](size_t x)
{
    if (x >= m_size) {
        // Allocate AllocSize past the element the client wants
        resize(x + AllocSize);
    }
    return m_elements[x];
}

이렇게 하면 앞에 나온 코드처럼 배열 인덱스로 표현해도 문제없이 컴파일됩니다. operator[]는 x 지점의 원소를 레퍼런스로 리턴하기 때문에 원소를 설정하거나 가져오는 데 모두 활용할 수 있습니다. 이렇게 리턴된 레퍼런스는 원소를 대입할 때도 활용할 수 있습니다. operator[]가 대입문의 좌변에 있으면 m_elements 배열의 x지점의 원소값이 실제로 변경됩니다.

 

5.1 읽기 전용 operator[] 만들기

operator[]가 원소를 좌측값으로 리턴하면 편하지만, 반드시 이렇게만 동작한다면 조금 아쉽습니다. 배열의 원소를 const 레퍼런스로 리턴해서 읽기 전용으로 접근하는 기능도 있다면 더 좋습니다. 따라서 operator[]를 두 버전으로 만들어 하나는 레퍼런스를, 다른 하나는 const 레퍼런스를 리턴하게 만듭니다.

T& operator[](size_t x);
const T& operator[](size_t) const;

 

앞에서도 설명했지만, C++에서는 메소드나 연산자의 리턴 타입만 다르게 해서 오버로딩할 수 있습니다. 따라서 두 번째 버전은 리턴할 레퍼런스 뿐만 아니라 메소드 전체도 const로 지정합니다. const operator[]를 구현하는 방법은 다음과 같습니다. 인덱스가 배열의 범위를 범어나면 새 공간을 할당하지 않고 예외를 던집니다. 원소값만 읽는데 공간을 추가로 할당할 이유가 없기 때문입니다.

template<typename T>
const T& Array<T>::operator[](size_t x) const
{
    if (x >= m_size) { throw std::out_of_range{ "" }; }
    return m_elements[x];
}

 

이렇게 정의한 두 가지 버전의 operator[]는 다음과 같이 사용할 수 있습니다.

void printArray(const Array<int>& arr)
{
    for (size_t i{ 0 }; i < arr.getSize(); i++) {
        std::cout << arr[i] << " "; // Calls the const operator[] because arr is
                                    // a const object.
    }
    std::cout << std::endl;
}

int main()
{
    Array<int> myArray;
    for (size_t i{ 0 }; i < 10; i++) {
        myArray[i] = 100; // Calls the non-const operator[] because
                          // myArray is a non-const object.
    }
    printArray(myArray);
}

이 코드에 나온 printArray()에서 const operator[]가 호출된 이유는 오로지 인수로 전달된 arr이 const이기 때문입니다. arr이 const가 아니었다면 결과를 수정하지 않고 읽기만 하더라도 non-const 버전의 operator[]가 호출됩니다.

 

const operator[]는 const 객체에 대해서만 호출됩니다. 따라서 배열의 크기를 조절할 수 없습니다. 앞에서 구현한 코드는 주어진 인덱스가 범위를 벗어나면 예외를 던집니다. 이렇게 예외를 던지지 않고 0으로 초기화된 배열을 리턴해도 되는데, 예를 들면 다음과 같습니다.

template<typename T>
const T& Array<T>::operator[](size_t x) const
{
    if (x >= m_size) { 
        //throw std::out_of_range{ "" };
        static T nullValue{ T{} };
        return nullValue;
    }
    return m_elements[x];
}

여기서 사용한 nullValue라는 static 변수는 zero-initialization 문법 T{}로 초기화됩니다. 예외를 던지는 방식과 널 값을 리턴하는 방식 중 어느 것으로 구현할지는 전적으로 개발자의 성향과 프로그램의 요구사항에 따라 결정됩니다.

 

5.2 배열의 인덱스가 정수가 아닐 때

인덱스를 키 값으로 볼 수도 있습니다. 예를 들어 vector(또는 linear array)는 배열의 위치를 키 값으로 사용하는 특수한 경우로 볼 수 있습니다. operator[]를 키에 대한 집합을 값에 대한 집합으로 대응시키는 함수라고 생각할 수 있습니다. 따라서 operator[]의 인덱스를 정수가 아닌 다른 타입으로도 얼마든지 표현할 수 있습니다. 대표적인 예로 표준 라이브러리의 std::map과 같은 연관 컨테이너(associative container)를 들 수 있습니다. 

 

예를 들어 연관 배열에서 다음과 같이 정수가 아닌 string으로 키 값을 지정하도록 정의할 수 있습니다.

template<typename T>
class AssociativeArray
{
public:
    virtual ~AssociativeArray() = default;
    
    T& operator[](std::string_view key);
    const T& operator[](std::string_view key) const;
private:
    // .. 구현 코드 생략
};

 

 


다음 포스트에 이어서 연산자 오버로딩에 대해 계속 진행하겠습니다 !

[C++] 연산자 오버로딩 (2)

 

'프로그래밍 > C & C++' 카테고리의 다른 글

[C++] Iterator (이터레이터, 반복자)  (0) 2022.02.21
[C++] 연산자 오버로딩 (2)  (0) 2022.02.20
[C++] I/O 스트림  (0) 2022.02.19
[C++] static 키워드 (+ extern)  (0) 2022.02.18
[C++] 캐스팅(Casting)  (0) 2022.02.17

댓글