Computer Science/C & C++

[C++] Operator Overloading | 연산자 오버로딩

lww7438 2020. 2. 27. 13:07

Operator Overloading

연산자 오버로딩

- 이미 정의되어 있는 연산자를 사용자 정의 데이터형에 최적화시키는 기능이다.
- C++의 Polymorphism(다형)적 특성을 잘 보여주는 기능이며, OOP가 지향하는 내부 구현사항의 추상화의 목적에 부합한다.

// Ex. 오버로딩된 덧셈 연산자(+)의 사용 예시

// (x.1
class Stock {
    ...
};

Stock st1, st2;
...
st1 = st1 + st2;     // 사용자 정의 클래스의 객체인 st1과 st2를 직접적으로 더하고 있다.


// Ex.2
for (int i = 0; i < 20; i++)
    evening[i] = sam[i] + janet[i];
    
// 덧셈 연산자를 해당 자료형에 오버로딩 시켜 덧셈 표현을 간소화 할 수 있다. (위 구문과 아래 구문은 수행하는 작업이 동일하다.)

evening = sam + janet;

Operator Function Definition (연산자 함수 정의)

- 연산자 또한, 작업사항이 함수에 구현되어 있으므로, 오버로딩하고자 하는 연산자를 사용자 정의 데이터형에 맞춰서 재정의가 필요하다.

// Ex. 연산자 함수의 일반형 예시

operatorOp (argumentList) {   // 여기서 operator는 키워드이며, Op는 오버로딩할 연산자 "기호"를 의미한다.
    ...
}

- 여기서 \(\texttt{Op}\)는 오버로딩할 연산자의 기호(+ - * / % 와 같은)를 의미한다.

// Ex. 연산자 함수 정의 예시

class Time {
private:
    int hours;
    int minutes;
public:
    ...
    Time(int h, int m) {hours = h; minutes = m;}
    Time operator+(const Time&) const;    // 매개변수로 받는 객체와 this가 가리키는 멤버 일체를 변경하지 않는다는 의미의 두 const 키워드 
    ...
};

...

Time Time::operator+(const Time& t) const {
    Time sum;
    sum.minutes = this->minutes + t.minutes;               // 이 구문과 아래 구문에서 this-> 구문은 생략해도 무방하다.
    sum.hours = this->hours + t.hours + t.t.minutes/60;
    sum.minutes %= 60;
    return sum;
}

// Constructor를 이용한 오버로딩 방법 (권고되는 방식)
Time Time::operator+(const Time& t) const {
    return Time(this->hours + t.hours + t.t.minutes/60,(this->minutes + t.minutes) % 60);
}
// 이 경우에선 가독성이 떨어져 보일 수 있으나,
// 생성자를 이용해서 리턴하는 방식이 오류를 일으킬 확률도 적고,
// 올바른 방법으로 간주되므로 가능한 경우라면 이러한 방법으로 구현하는 것이 좋다.

Using the Operator Function (연산자 함수 호출, 연산자 사용)

// Ex. 오버로딩된 연산자의 내부 구현 예시

// 어떤 객체에 대해 덧셈 연산자(+)가 오버로딩 되었다고 가정하자.

district2 = sid + sara;            // Operator Notation (연산자 표기)
// 프로그래머가 작성한 이 구문은,

district2 = sid.operator+(sara);   // Function Notation (함수 표기)
// 내부적으로는 이렇게 재구성되어 표현된다. (이러한 사항은 프로그래머에게는 보이지 않는다.)

// 여기서, = 연산자는 계속해서 유지가 되는 모습을 보이는데,
// 대입 연산자(=)는 사용자 정의 데이터형을 포함하여 모든 클래스에 자동으로 오버로딩 되기 때문이다.

- 위 코드에서 보다시피, 메서드를 호출한, 연산자의 왼쪽 객체는 Implicit(암시적)으로 표기되며,
매개변수로써 전달된, 연산자의 오른쪽 객체는 Explicit(명시적)으로 표기되는 규칙을 가진다.
- 사용자는 취향껏, 연산자 표기를 사용해도 되고, 함수 표기를 사용해도 된다. 결국, 프로그램 내부적으로는 함수 표기로 작동된다는 점만 숙지하고 있으면 된다.


Implicit Object and Explicit Object in Operator Function (연산자의 좌항과 우항)

- 이항 연산자 함수를 호출하는 두 객체의 위치에 관한 개념이다.
- 좌항에 위치하여 암시적으로 연산자 함수를 주도적으로 호출한 데이터 객체는 당연히 상수일 수 없다.
- 우항에 위치하여 연산자 함수에 매개변수로써 전달되는 데이터 객체는 클래스 객체일 수도 있고, 상수일 수도 있다.

// Ex. 연산자 함수를 사용하는 피연산자 예시

Time Time::operator+(const double& d) const {...}   // 매개변수가 double형임에 유의!
...

Time time1, time2;
...

time2 = time1 + 13.5;      // 오버로딩 된 형태이다. (Time객체 + double상수)

time2 = 13.5 + time1;      // 오버로딩 되지 않은 형태이므로, 오류가 발생한다. (double상수 + Time 객체)
// 이 경우처럼 좌항이 데이터 객체가 아닌 경우에는, 연산자 함수를 외부에서 정의하고 friend를 통해 Grant하는 방법밖에 없다.


※ 멤버 함수로써 정의되는 연산자 함수는 해당 연산자의 피연산자 개수 보다 하나 적은 매개변수를 요구하고,
외부 함수로써 정의되고 \(\texttt{friend}\)로 Grant되는 연산자 함수의 경우엔 해당 연산자의 피연산자 개수 만큼의 매개변수를 가져야 한다.
- 실제로 컴파일러는 이 원리를 이용하여 어떤 연산자 함수가 오버로딩하는 연산자가 Unary Operator(단항 연산자)인지, Binary Operator(이항 연산자)인지를 판단한다.
ex) \(\texttt{-}\) 연산자는 단항 연산자로써는 어떤 값의 부호를 역전시키는 역할을 하고, 이항 연산자로써는 두 값에 대한 뺄셈을 수행하는 역할을 하는데, 컴파일러는 이 연산자 함수의 매개변수 개수를 보고 단항 연산자가 오버로딩된 것인지, 이항 연산자가 오버로딩된 것인지를 판단하게 된다.

※ 같은 클래스에서 같은 연산자 함수를 멤버 함수 버전과 프렌드 함수 버전 두 가지를 동시에 오버로딩할 경우, 해당 연산자 함수를 호출하는 과정에서 Ambiguity Error(모호성 에러)가 발생할 수 있으므로 한 버전만 정의해야 한다. (굳이 두 가지 버전 모두를 정의해야 하는 경우가 있는지도 모르겠다.)



Restrictions for Overloaded Operator (오버로딩된 연산자에 대한 제약사항)

- 오버로딩 할 연산자는 C++에 기정의된 적법한 연산자이어야 한다.

- 사용자 정의 데이터형에 맞게 오버로딩된 연산자를 사용하는 피연산자 중, 하나 이상은 반드시 해당 사용자 데이터형이어야 한다. 다시 말해, 표준 데이터형을 위해 기정의된 연산자를 수정할 수 없음을 의미한다.

- 연산자에 요구되는 피연산자의 개수를 바꿀 수 없다.
ex) 단항 연산자는 1개의 피연산자만을 수용할 수 있고, 이항 연산자는 2개의 피연산자만을 수용할 수 있다.

- 컴파일러에 정의되어 있는 연산자 우선순위는 변경할 수 없다.

- 연산자 기호를 새로 만들 수는 없다.

- 오버로딩할 연산자에 기대되는 직관적인 연산을 수행하도록 구현해야 한다.
ex) 한 사용자 정의 \(\texttt{class}\)에 대한 \(\texttt{*}\) 연산자는, 직관적으로 어떤 값들을 곱하는 작업이 기대되는데, 곱셈 작업이 아니라 대입, 뺄셈과 같은 비상식적인 작업을 수행하게 구현하게 되면, 따로 설명이 없는 이상 사용자도 해당 연산자를 쉽게 다루지 못하게 되고, 컴파일러도 이를 막을 방법이 없다.

- 아래 표의 연산자들은 오버로딩할 수 없다.

Operator Work
\(\texttt{sizeof}\) \(\texttt{}\)
\(\texttt{.}\) 멤버 연산자
\(\texttt{.  *}\) 멤버 지시 포인터 연산자
\(\texttt{::}\) 사용 범위 결정 연산자
\(\texttt{?:}\) 조건 연산자 (삼항 연산자)
\(\texttt{typeid}\) \(\texttt{RTTI}\) 연산자
\(\texttt{const_cast}\) 데이터형 변환 연산자
\(\texttt{dynamic_case}\) 데이터형 변환 연산자
\(\texttt{reinterpret_cast}\) 데이터형 변환 연산자
\(\texttt{static_cast}\) 데이터형 변환 연산자


- 아래 표의 연산자들은 멤버 함수를 통해서만 정의할 수 있다.
(일반 함수로 정의하고 \(\texttt{friend}\)로 Grant하는 것을 허용하지 않는다.)

Operator Work
\(\texttt{=}\) 대입 연산자
\(\texttt{()}\) 함수 호출 연산자
\(\texttt{[]}\) 배열 인덱스 연산자
\(\texttt{->}\) 클래스 멤버 접근 포인터 연산자


* 오버로딩 가능한 42개의 연산자들

\(\texttt{+}\) \(\texttt{-}\) \(\texttt{*}\) \(\texttt{/}\) \(\texttt{%}\) \(\texttt{^}\) \(\texttt{&}\) \(\texttt{|}\) \(\texttt{~}\) \(\texttt{!}\)
\(\texttt{=}\) \(\texttt{<}\) \(\texttt{>}\) \(\texttt{+=}\) \(\texttt{-=}\) \(\texttt{+}\) \(\texttt{*=}\) \(\texttt{/=}\) \(\texttt{%=}\) \(\texttt{^=}\)
\(\texttt{&=}\) \(\texttt{|=}\) \(\texttt{<<}\) \(\texttt{>>}\) \(\texttt{>>=}\) \(\texttt{<<=}\) \(\texttt{==}\) \(\texttt{!=}\) \(\texttt{<=}\) \(\texttt{>=}\)
\(\texttt{&&}\) \(\texttt{||}\) \(\texttt{++}\) \(\texttt{--}\) \(\texttt{,}\) \(\texttt{->*}\) \(\texttt{->}\) \(\texttt{()}\) \(\texttt{[]}\)  
\(\texttt{new}\) \(\texttt{delete}\) \(\texttt{new []}\)
\(\texttt{delete []}\)        

\(\texttt{<<}\) Operator Overloading (\(\texttt{<<}\) 연산자 오버로딩)

- \(\texttt{<<}\) 연산자를 사용자 정의 클래스형에 오버로딩하게 되면, \(\texttt{cout, fout}\) 객체와 같이 사용할 수 있게 되어, 출력 구문을 보다 편리하게 작성할 수 있게 된다. (특히, 한 번에 출력해야 할 멤버가 많은 경우에 효과적이다.)
+) \(\texttt{<<}\) 연산자는 이미 여러 경우에 대해 오버로딩 되어있다. 비트 조작 연산자이기도 하며, \(\texttt{ostream}\) 클래스 내에 많은 기본 데이터형들에 대해서도 출력 연산자로써 오버로딩 되어있다. (\(\texttt{cout}\)은 \(\texttt{ostream}\) 클래스 객체이다.)

- \(\texttt{<<}\) 연산자는 오직 프렌드 함수만을 이용해서 오버로딩할 수 있고, 멤버 함수로써의 구현은 불가능하다.
(\(\texttt{<<}\) 연산에서 왼쪽에 오는 객체(함수를 호출하는 주체)는 \(\texttt{ostream}\) 클래스의 객체 \(\texttt{cout}\) 따위이기 때문이다.)

// Ex. << 연산자 오버로딩 예시

class Time{
...
    friend ostream& operator<<(ostream&, const Time&);
};

ostream& operator<<(ostream& os, const Time& t) {   // 매개변수 os의 후보로는 ostream의 다양한 객체들이다. (cout, cerr 등)
    os << t.hours << "시간, " << t.minutes << "분\n";
    return os;     // os객체를 다시 리턴함으로써, 연속적인 출력(연속적인 << 연산)이 가능하게 한다.
}

...

Time trip1, trip2;
...
cout << trip1 << trip2;    // trip의 멤버별로 따로 출력할 필요없이 간결하다.
// operator<<() 함수의 리턴형이 ostream&형이므로, <<연산자를 연속적으로 사용할 수 있다.

- \(\texttt{ostream<<()}\) 함수가 \(\texttt{ostream}\) 클래스의 \(\texttt{private}\) 멤버에는 접근할 일이 없으므로, \(\texttt{ostream}\) 클래스에는 Grant될 필요가 없다.
- 매개변수로 \(\texttt{ostream}\)이 아닌, \(\texttt{ostream&}\) (참조형)이어야 하는 이유는 \(\texttt{cout}\) 객체가 복사본이 아닌, 원본이어야 정상적으로 작동하기 때문이다.
- 또한, \(\texttt{Time}\) 객체도 참조형으로 전달되는데, 이는 메모리와 실행시간을 개선하기 위함이다. (복사를 하지 않아도 되므로)
- \(\texttt{operator<<()}\)의 리턴형이 \(\texttt{void}\)형이 아닌, \(\texttt{ostream&}\)형인 이유는, \(\texttt{ostreamObject << classObject}\) 구문이 출력을 마치고, 다시 그자리에 \(\texttt{ostreamObject}\) 객체를 리턴해서 연속적인 \(\texttt{<<}\) 연산이 가능하게 하기 위함이다.
- 클래스 상속 관계에 따라, 매개변수로 \(\texttt{ostream}\)가 올 수 있게 되면, \(\texttt{ofstream}\)객체 또한 매개변수로 올 수 있게 된다.


Assignment(\(\texttt{=}\)) Operator Overloading (대입(\(\texttt{=}\)) 연산자 오버로딩)

- 대입 연산자는 한 객체를 다른 객체에 대입할 때 사용하는 연산자이다.
(이 때 "객체"는 기본 데이터형 뿐만 아니라, 사용자 정의 클래스에서 파생된 객체를 포함한다.)
- 대입 연산자는 객체를 초기화할 때 반드시 사용되는 것은 아니다. (대입 연산자를 사용하지 않고도 초기화가 가능하게 하는 C++의 문법이 존재하기 때문이다.)
- 대입 연산자는 클래스 멤버 함수에 의해서만 오버로딩 가능하다.
- 복사 생성자에서 Shallow Copy(얕은 복사)가 일으키는 문제점과 동일하게, 대입과정에서 포인터 멤버에 대한 얕은 복사가 문제를 일으킬 수 있다면, Deep Copy(깊은 복사) 방식의 대입 연산자 함수 구현이 필요하다.
(얕은 복사 - 깊은 복사 개념 포스트 참조)

※ 복사 생성자와 대입 연산자 Deep Copy 구현방식 차이
- 복사 생성자는 새로운 객체를 초기화할 때 사용되는 것과 달리, 대입 연산자는 기존에 존재하고 있던 객체를 다룬다는 점이다.
- 대입 연산자 함수는 타깃 객체가 이전에 대입된 데이터를 참조하고 있을 수 있으므로, \(\texttt{delete []}\)를 통해 이전에 참조하는 데이터를 해제해야 한다. 해제하지 않은 채, 메모리를 새로 대입하면 Memory Leak(메모리 누수)가 발생하게 된다.
- 대입 연산자 함수는 호출한 객체에 대한 참조를 리턴하여, 대입 연산을 사슬처럼 연결하는 것을 허용하게 한다.
- 대입 연산자 함수를 호출한 주체가 되는 객체가 자기 자신을 대입하지 않도록 막는 장치가 있어야 한다. 이를 막지 못하면, 내용을 다시 대입하기도 전에 앞선 원칙에서 말했던 메모리 해제를 행할 것이기 때문이다.
- 자기 자신의 대입을 막는 방법의 예시로, \(\texttt{if}\) 구문을 통해 \(\texttt{this}\) 포인터가 가리키는 객체와 매개변수로 받은 객체가 같은 경우, \(\texttt{*this}\)를 리턴하고 해당 대입 연산자 함수를 종료하게 하는 방법이 있다.

// Ex. Deep Copy 방식의 대입 연산자 함수 구현 예시

class StringBad {
private:
    int len;
    char* str;
public:
    StringBad& operator=(const StringBad&);
    ...
};

StringBad& StringBad::operator=(const StringBad& st) {
    if (this == &st)     // 종료하지 않으면, 자기 자신을 대입하기도 전에 메모리 해제가 이루어지게 된다.
        return *this;
        
    delete [] str;       // this 객체와 매개변수 객체가 같지 않다는 보장하에 수행된다.
    len = st.len;
    str = new char[len + 1];   // Deep Copy 방식임을 알 수 있다.
    str::strcpy(str, st.str);
    return *this;
}


※ 대입 연산자 또한, 프로그래머가 정의해놓지 않았을 경우, 디폴트 대입 연산자를 자동으로 정의한다.
- 디폴트 대입 연산자는 얕은 복사 방식으로 구현된다.
- 디폴트 대입 연산자는 \(\texttt{static}\) 멤버는 취급하지 않는다.

// Ex. 디폴트 대입 연산자 함수의 기본형

className& className::operator=(const className&);


※ 한 데이터형을 다른 데이터형에 대입하는 두 가지 방법
1. 연산자 함수를 명시적으로 오버로딩하는 방법
- 두 번째 방식보다, 실행시간 측면에서 우수하지만 비교적 더 많은 코드가 요구된다.

2. 변환 함수를 정의하는 방법
- 호출하는 방식에 따라 컴파일러에 Ambiguity Error를 발생하게 할 수 있다.


Array Index Operator(\(\texttt{[ ]}\)) Operator Overloading (배열 인덱스(\(\texttt{[ ]}\)) 연산자 오버로딩)

- 사용자 정의 클래스에서 파생된 객체에도 \(\texttt{[ ]}\) 표기(Braket Notation; 대괄호 표기)를 사용하여, 객체가 저장하고 있는 배열 멤버의 \(\texttt{n}\)번째 원소에 접근할 수 있다.
- 라이브러리인 \(\texttt{string}\) 객체에도 배열 표기를 사용하여 문자열 내에 각각의 문자에 접근할 수 있다는 점이 대표적인 \(\texttt{[ ]}\) 연산자 오버로딩의 예시이다.
- \(\texttt{[ ]}\) 연산자는 이항 연산자이다. 배열명으로 사용되는 피연산자와 인덱스 값(정수)으로 사용되는 피연산자로 구성되어 있다.

// Ex. [] 연산자의 피연산자 예시

std::string str = "Hello World_!";
str[5] = "w";     // 여기서 [] 연산자의 피연산자는 str과 5이다.

 

// Ex. 사용자 정의 클래스 String에서의 [] 연산자 오버로딩 예시

class String {
private:
    int len;
    char* str;
    ...
};

// Version 1 (Non-const)
char& String::operator[] (int i) {    // 이 버전에서는 const로 선언된 String 객체를
    return str[i];                    // 수정하지 못할 뿐만 아니라, (const이므로 수정이 불가한건 당연하다.)
}                                     // 출력또한 하지 못하게 된다. (const로 선언되지 않았으므로)

// Version 2 (for-const)
const char& String::operator[] (int i) const {   // 이 버전의 함수는 const로 선언된 String 객체에
    return str[i];                               // Read-only로 접근 가능하게 하기 위함이다. 
}

String Opera(Aria);
const String Pop("Jazz master");
// String(char*) 생성자가 정의되어 있다고 가정하자.

cout << Pop[4];
// 이 구문은 아래 구문으로 변환할 수 있다.
cout << Pop.operator[](4);

Opera[2] = "A";    // char의 참조(char&)를 리턴하므로 수정또한 가능하다.(Non-const 타입 객체에 한정)

- 배열명은 \(\texttt{[ ]}\) 연산자 함수의 첫 번째 매개변수에 해당하고, 인덱스 값은 \(\texttt{[ ]}\) 연산자 함수의 두 번째 매개변수에 해당한다.
- \(\texttt{[ ]}\) 연산자가 데이터의 참조를 리턴하기 때문에, 일반 배열 데이터를 다룰 때 처럼, 특정 인덱스 값을 수정할 수도 있게된다.
- 위와 같이  \(\texttt{const}\)를 위한 버전도 따로 정의해야 보통의 객체는 Read/Write이 가능하고, \(\texttt{const}\)로 선언된 객체는 Read가 가능해진다.


Comparison Operator Overloading (비교 연산자 오버로딩)

- 비교 연산자는 \(\texttt{>, <, ==}\)와 같이 대소관계를 비교하여 \(\texttt{bool}\) 값을 리턴하는 연산자를 의미한다.
- 비교 연산자 함수를 \(\texttt{friend}\)를 통해 외부에서 정의한 다음 클래스로 Grant하는 방법으로, \(\texttt{string}\) 객체와 C-Style 문자열을 비교하는 함수를 오버로딩할 수 있다.

// Ex. 비교 연산자 오버로딩 (사용자 정의 String 객체와 C-Style 문자열)

class String {
private:
    int len;
    char* str;
...
};

String::String(const char* s) {    // 이 생성자는 매개변수로 C-Style 문자열을 받는다.
    len = std::strlen(s);          // 따라서, String 객체를 요구하는 구문에
    str = new char[len + 1];       // C-Style 문자열을 입력할 경우엔
    std::strcpy(str, s);           // 이 생성자를 통해 String 객체로 변환된다.
}

bool operator<(const String& st1, const String& st2) {
    return (std::strcmp(st1.str, st2.str) < 0);
}

bool operator>(const String& st1, const String& st2) {
    return (st2 < st1);      // < 연산자 함수를 이용한다.
}

bool operator==(const String& st1, const String& st2) {
    return (std::strcmp(st1.str, st2.str) == 0);
}

...

String answer;
// answer가 특정 문자열로 초기화되었다 가정하자.

if ("love" == answer)
// 이 구문은 아래 구문으로 변환할 수 있다.
if (operator==("love", answer))
// 또한, 이 구문은 아래 구문으로 변환할 수 있다.
if (operator==(String("love"), answer))    
// C-Style 문자열이 String(const char*) 생성자를 통해 데이터형 변환이 이루어진다.