ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [C++] 연산자
    C++/이것이 C++이다 2021. 7. 16. 16:15

    연산자는 우리가 잘 알고 있는 산술연산자 (+, -, ×, ÷) 논리연산자 (AND, OR... etc)를 의미합니다.

    그리고 C++ 에서는 이러한 연산자를 함수 형태로 쓸 수 있는 연산자 함수를 제공합니다.

    연산자 함수를 통해 함수를 오버로딩 하듯 연산자 오버로딩이 가능해지고

    이것은 객체를 정의함에 있어서 고도의 일반화, 추상화로 이어집니다.

    이번 글에서는 연산자 함수와 연산자 오버로딩에 대해 살펴 볼 것입니다.

     


     

    1. 연산자 함수

     

    더보기

    연산자 함수는 말 그대로, 연산자를 사용하듯 호출하는 메소드를 의미합니다.

    이전 글(깊은 복사와 얕은 복사)에서 살펴본 대입 연산자 또한 이에 속합니다.

    클래스에 적용이 가능한 연산자 함수를 정의한다면 사용자는 더욱 편하게 클래스를 이용할 수 있습니다.

    이것은 코드의 생산성과 확장성을 비약적으로 상승시킬 수 있는 방법 중 하나가 됩니다.

     

    하지만, 연산자 함수는 절대로 오류가 발생하면 안된다는 점을 유의해야 합니다.

    1+1 이라는 연산이 제대로 작동하지 않거나, 2가 되지 않는다는 경우를 고려하지 않는 것과 마찬가지입니다.

    연산자 함수를 정의함으로 인해서 생기는 이점 (사용 편의성, 코드의 간결성)은 무시할 수 없기 때문에 이것을 사용하지 않는다는 것은 생각할 수 없지만, 위와 같은 문제를 충분히 고려한 후 사용해야 할 것입니다.

     

    추가적으로, 논리 연산자는 오버로딩 하면 안 됩니다.

    이에 관해서는 읽어 볼 만한 외부 링크를 첨부하도록 하겠습니다.

    논리 연산자의 오버로딩은 좋지 않은 아이디어인가?
    논리 연산자를 오버로딩 하면 안 되는 이유

    요약하자면 다음과 같습니다.

    논리 연산자를 오버로딩 할 경우, 이것에 대한 작동 방식 등을 판단해야 하는데, 이것은 올바른 관행이 아니다.(= AND연산의 경우, 좌항과 우항이 참이면 결과도 참인데, 이것을 따로 판단하지 않는다)
    이로 인해서 발생하는 버그는 개발자, 사용자에게 찾기 힘든 버그를 발생시킬 수 있다.
    그러므로, 논리 연산자는 오버로딩 하면 안 된다.

     

    2. 산술 연산자 ( +, -, *, / ... )

     

    더보기

    기본 자료형에 대한 산술 연산은 매우 자연스럽습니다.

    가령, int형 1과 2에 대한 +연산의 결과값은 3입니다.

     

    하지만, 숫자가 아닌 것 (기본 자료형이 아닌 것) 에 대한 연산의 경우는 생각해보기 난해합니다.

    A + B는 AB가 될 수도, 16진수의 연산으로 생각해본다면 0x15가 될 수도 있습니다.

    중요한 것은, 무엇이든 덧셈이 가능하다는 것이고, 어떻게 생각하냐에 따라 다르다는 것입니다.

     

    예제를 살펴보도록 하겠습니다.

    #include <iostream>
    using namespace std;
    
    class MyClass {
    public:
        MyClass(int val) : my_value(val) {
            cout << "Construct : (int) " << my_value << "\n";
        }
        MyClass(const MyClass& rhs) : my_value(rhs.my_value) {
            cout << "Copy Construct : MyClass " << my_value << "\n";
        }
        MyClass(const MyClass&& rhs) : my_value(rhs.my_value) {
            cout << "Move Construct : MyClass " << my_value << "\n";
        }
    
        operator int() { return my_value; }
    
        MyClass operator+(const MyClass& rhs) {
            cout << "Operator +\n";
            MyClass result(0);
            result.my_value = this->my_value + rhs.my_value;
    		
            return result;
        }
    	
        MyClass& operator=(const MyClass& rhs) {
            cout << "Operator =\n";
            my_value = rhs.my_value;
    
            return *this;
        }
    
    private:
        int my_value = 0;
    };
    
    int main()
    {
        MyClass a(1), b(2), c(3);
        a = b + c; // Line 40
        cout << a << "\n";
        
        cout << "----------------\n";
    	
        d.operator=(e.operator+(f)); // Line 45
        cout << d << "\n";
        
        return 0;
    }
    Construct : (int) 1
    Construct : (int) 2
    Construct : (int) 3
    Operator +
    Construct : (int) 0
    Move Construct : MyClass 5
    Operator =
    5
    ----------------
    Operator +
    Construct : (int) 0
    Move Construct : MyClass 5
    Operator =
    5

    MyClass에 대한 덧셈 및 대입 연산자를 오버로딩 한 예제입니다.

    출력을 보면, 코드의 주석으로 표시 된 Line 40과 Line 45는 같게 동작하는 것을 볼 수 있습니다.

    사용자 코드에서는 간결해보이는 단순한 덧셈 연산내부적으로는 그것보다는 약간 복잡하게 동작하고 있는 것입니다.

    우선 b + c가 실행되면 MyClass operator+(const MyClass& rhs) 연산자 함수가 호출됩니다.

    이 연산의 결과가 임시객체가 되어, a의 MyClass operator=(const MyClass& rhs) 가 호출되는 것 입니다.

     

    지난 글(깊은 복사와 얕은 복사)에서, 임시 객체의 생성을 줄이기 위해 참조를 많이 활용하는 것이 성능 면에서 좋다는 언급이 있었습니다.

    하지만, 대입 연산자와 같은 경우에는 임시 객체를 이용할 수 밖에 없는 상황입니다.

    기본 자료형과 같은 간단한 형식의 경우에는 대입 전의 중간 결과를 CPU의 레지스터에 담을 수 있겠지만 위와 같은 인스턴스는 메모리에 올라가야 합니다.

    클래스를 이용한 연산자 오버로딩을 줄이거나, 하지 말아야된다는 내용이 아닙니다.

    하지만 기본 자료형과 비교했을 때, 사용자 코드에서는 큰 차이가 없지만 내부적으로는 상대적으로 성능이 떨어진다는 것을 기억해야 할 것입니다.

     

    위에서는 덧셈 연산에 대해서, 같은 형식 (같은 클래스)에 대해서만 언급하였지만 다른 연산도 동일하게 작동하고, 다른 형식에 대해서도 해당 형식에 대한 오버로딩을 하면 됩니다.

     

    예를 들어, 위의 MyClass에 대한 int 형의 덧셈 연산은 아래와 같이 정의할 수 있습니다.

    #include <iostream>
    using namespace std;
    
    class MyClass {
    public
        MyClass operator+(int value) {
            cout << "Operator +\n";
            result = value + this->my_value;
            
            return result;
        }
    
        MyClass operator+(const MyClass& rhs) {
            cout << "Operator +\n";
            MyClass result(0);
            result.my_value = this->my_value + rhs.my_value;
    		
            return result;
        }
    
    private:
        int my_value = 0;
    };

    위 오버로딩에 대한 자세한 설명은 생략하도록 하겠습니다.

     

    연산자에 대한 주제가 나온 김에 사족을 달아보자면 cout과 cin은 각각 ostream, istream의 인스턴스입니다.

    위 인스턴스는 전역 변수 형태로 선언되어 있으며 각각 여러 객체 (자료형 포함)에 대한 <<, >> 연산자가 오버로딩 되어 있습니다.

    자세한 내용은 기회가 된다면 다루어보도록 하겠습니다.

    이런 식으로 선언, 구현되어 있다.

     

     

    3. 대입 연산자 ( = )

     

    더보기

    단순 대입 연산자를 다시 한번 살펴보겠습니다.

    #include <iostream>
    using namespace std;
    
    class MyClass {
    public:
        explicit MyClass(int param){
            my_value = new int(param);
        }
        
        ~MyClass() { delete my_value; }
        
        operator int(){ return *my_value; }
        
        void operator=(const MyClass& rhs) { // Line 14
            delete my_value;
            
            my_value = new int(*rhs.my_value); // Line 17
        }
    
    private:
        int *my_value = nullptr;
    };
    
    int main(){
        MyClass a(0), b(1);
        a = b;
        cout << a << "\n";
        
        return 0
    }
    1

    위 코드는 문제없이 잘 실행됩니다.

    하지만, 두 가지 문제점이 존재합니다.

     

    하나씩 살펴보도록 하겠습니다.

    #include <iostream>
    using namespace std;
    
    int main(){
        MyClass a(0);
        a = a;
        cout << a << "\n";
        
        return 0
    }
    -12341234

    main함수의 대입 부분이 a = b에서 a = a로 바뀌었습니다.

    자기 자신을 대입하는 것이 보통의 상황은 아니지만, 문법적으로 문제는 없습니다.

    따라서 아무런 변화가 없이 잘 실행되어야 하지만, 출력은 그렇지 않고, 이상한 값이 출력되었습니다.
    (예제 출력은 임의로 적은 값 입니다.)

     

    이유는, 대입 연산자 함수에서 대입 이전에 삭제를 했기 때문입니다.

    위와 같은 상황을 방지하기 위해, 일반적으로는 아래와 같이 자기 자신에 대한 대입에는 대응하지 않습니다.

    #include <iostream>
    using namespace std;
    
    class MyClass {
    public:
    
        void operator=(const MyClass& rhs) {
            if(this == &rhs) // Line 8
                return;
        
            delete my_value;
            
            my_value = new int(*rhs.my_value);
        }
    
    private:
        int *my_value = nullptr;
    };

     

    다음 문제를 살펴보겠습니다.

    #include <iostream>
    using namespace std;
    
    int main(){
        MyClass a(0), b(1), c(3);
        a = b = c;
        cout << a << "\n";
        
        return 0
    }
    C2679 : 이항 '=': 오른쪽 피연산자로 'void'형식을 사용하는 연산자가 없거나 허용되는 변환이 없습니다.

    대입 연산자의 반환형이 void이기 때문에 발생하는 문제입니다.

    그래서 나오는 일반적인 대입 연산자의 형태가 문단 2에서 본 것과 유사한 형태입니다.

    #include <iostream>
    using namespace std;
    
    class MyClass {
    public:
    
        MyClass& operator=(const MyClass& rhs) {
            if(this == &rhs)
                return;
        
            delete my_value;
            
            my_value = new int(*rhs.my_value);
            
            return *this;
        }
    
    private:
        int *my_value = nullptr;
    };

    문단 2의 대입 연산자 함수와 다른것은, 포인터를 사용한 클래스 필드가 생겼다는 것 정도일 것 같습니다.

     

     

    4. 복합 대입 연산자 ( +=, -= ... )

     

    더보기

    복합 대입 연산자는 +=, -= 등의 연산자를 의미합니다.

     

    다른 연산자 함수와 크게 다를 것이 없기 때문에, 간단한 예제를 바로 살펴보도록 하겠습니다.

    #include <iostream>
    using namespace std;
    
    class MyClass {
    public:
        explicit MyClass(int param){
            my_value = new int(param);
        }
        
        ~MyClass() { delete my_value; }
        
        MyClass& operator+=(const MyClass& rhs){
            int *new_value = new int(*my_value);
            
            *new_value += *rhs.my_value;
            
            delete my_value;
            my_value = new_value;
            
            return *this;
        }
    
    private:
        int *my_value = nullptr;
    };
    
    int main(){
        MyClass a(1), b(3), c(5);
        a += b;
        a += c;
        cout << a << "\n";
        
        return 0;
    }
    9

    위 코드 또한 포인터를 사용했기 때문에, 기존 데이터를 삭제하고 새 데이터로 교체하는 과정이 추가되었습니다.

    그 외의 자세한 설명은 생략하도록 하겠습니다.

     

    5. 이동 대입 연산자

     

    더보기

    연산에 의한 임시 결과는 두 종류가 존재합니다.

    첫 번째는 a + b와 같은 연산에 의한 값

    두 번째는 함수 호출에 의한 값이다.

    자세한 내용은 이전 글(깊은 복사와 얕은 복사)[문단 7. 이름 없는 임시 객체]에서 다루었으므로 생략하도록 하겠습니다.

    생각해야 할 점은, 임시 객체에 의해 이동 생성자가 생겨난 것 처럼 임시 객체에 의해 이동 대입 연산자가 생겨났다는 것입니다.

     

    예제를 살펴보도록 하겠습니다.

    #include <iostream>
    using namespace std;
    
    class MyClass {
    public:
        explicit MyClass(int param){
            cout << "Construct : MyClass \n";
            my_value = new int(param);
        }
        
        MyClass(const MyClass& rhs){
            cout << "Copy Construct : MyClass \n";
            my_value = new int(*rhs.my_value);
        }
        
        ~MyClass() { delete my_value; }
        
        operator int(){ return *my_value; }
        
        MyClass operator+(const MyClass& rhs){
            return MyClass(*my_value + *rhs.my_value);
        }
        
        MyClass& operator=(const MyClass& rhs) {
            cout << "Operator=\n";
            if(this == &rhs)
                return *this;
                
            delete my_value;
            my_value = new int(*rhs.my_value);
            
            return *this;
        }
        
        MyClass& operator=(MyClass&& rhs) {
            cout << "Operator= (Move)\n";
            
            my_value = rhs.my_value;
            *rhs.my_value = NULL;
            
            return *this;
        }
    
    private:
        int *my_value = nullptr;
    };
    
    int main(){
        MyClass a(0), b(1), c(2);
        cout << "-------------\n";
        a = b + c;
        cout << a << "\n";
        a = b;
        cout << a << "\n";
        
        return 0;
    }
    Construct : MyClass
    Construct : MyClass
    Construct : MyClass
    -------------
    Construct : MyClass
    Operator= (Move)
    3
    Operator=
    1

    main함수의 a = b + c와, a = b에서 호출하는 대입 연산자가 서로 다름을 볼 수 있습니다.

    첫 번째는 b + c의 결과값 즉, 임시 객체를 매개변수로 하기 때문에 이동 대입 연산자가 호출되는 것이고

    두 번째는 b 에 대한 참조를 하는 대입 연산자가 호출되는 것 입니다.

    두가지가 어떻게 다른지에 대해서는 이전 글(깊은 복사와 얕은 복사)[문단 9. 이동 시맨틱] 에서 다루었습니다.

     

    6. 배열 연산자 ( [ ] )

     

    더보기

    배열 연산자도 오버로딩이 가능합니다.

    사실, C와 C++에서 배열 연산은 내부적으로는 모두 포인터로 구성되어 있습니다.

    하지만, 편의성 측면에서 사용자는 대부분 포인터보다는 배열 방식을 선호할 것 입니다.

     

    이번에는 예제로 int 배열을 사용하는 것과 동일하지만

    내부적으로는 메모리를 직접 관리하는 클래스를 살펴 볼 것입니다.

    #include <iostream>
    using namespace std;
    
    class IntArray {
    public:
        IntArray(int size) {
            arr_data = new int[size];
            memset(arr_data, 0, sizeof(int) * size);
        }
    
        ~IntArray() { delete arr_data; }
    
        int operator[](int index) const {
            cout << "Operator[] const\n";
            return arr_data[index];
        }
    
        int& operator[](int index) {
            cout << "Operator[] \n";
            return arr_data[index];
        }
    
    private:
        int* arr_data;
    };
    
    void test_func(const IntArray& arr) {
        cout << "Test Function()\n";
        cout << arr[1] << "\n";
    }
    
    int main() {
        IntArray arr(5);
        for (int i = 0; i < 5; ++i) {
            arr[i] = i * 5;
        }
    
        test_func(arr);
    
        return 0;
    }
    Operator[]
    Operator[]
    Operator[]
    Operator[]
    Operator[]
    Test Function()
    Operator[] const
    5

    배열 연산자의 매개변수는 int형 하나입니다.

    하지만 연산자 함수는 두개가 정의된 것을 볼 수 있는데, 상수형 참조를 고려해야 하기 때문입니다.

    첫 번째 int& operator[]는 l-value를 고려해야 하기 때문에 int&를 반환했고

    두 번째 int operator[](int index) const는 상수형 참조를 통해서만 호출 할 수 있습니다.

     

    주목해야 할 부분은 test_func 부분입니다. 매개변수로 받아온 arr이 상수형 참조이기 때문에

    int operator[](int index) const가 호출되었습니다.

     

    위 클래스는 경계값 (배열의 크기 이상, 이하에 대한 참조)에 대한 검사를 하지 않은 클래스입니다.

    해당 문제에 대해서는 차후 자료구조의 array와 linked list에서 자세히 다루도록 하겠습니다.

     

    7. 관계 연산자 ( ==, !=, > ... )

     

    더보기

    관계 연산자는 상등 (==), 부등 (!=), 비교 (>, <)연산자를 합쳐서 부르는 말입니다.

    이 연산자들의 반환값은 int형으로, 각각의 함수 원형은 아래와 같습니다.

    int Class::operator==(const Class &);
    int Class::operator!=(const Class &);
    ...

    반환값이 음수인지, 양수인지, 0인지에 따라 결과를 판단하는 방식입니다.

     

    이전 문단의 IntArray에 위 연산자를 정의하면 '배열 단위'의 비교가 가능해집니다.

    하지만, 이보다 더 자주 사용되는 곳은 문자열입니다.

    예제를 살펴보도록 하겠습니다.

    int MyStringClass::operator==(const MyStringClass& rhs){
        if(my_string != NULL && rhs.my_string != NULL)
            if(strcmp(my_string, rhs.my_string) == 0)
                return 1;
        
        return 0;
    }
    
    int MyStringClass::operator!=(const MyStringClass& rhs){
        if(my_string != NULL && rhs.my_string != NULL)
            if(strcmp(my_string, rhs.my_string) == 0)
                return 1;
        
        return 0;
    }

    MyStringClass는 char형 배열을 통해 문자열을 다루는 클래스라 가정하도록 하겠습니다.

    위와 같이 상등, 부등 연산자를 오버로딩 하는 것으로 두 클래스가 가진 문자열을 비교할 수 있게 되었습니다.

     

    8. 단항 증감 연산자 ( ++, -- ... )

     

    더보기

    단항 증감 연산자는 for문에서 자주 볼 수 있는 연산자 입니다.

    단항 증가 연산자는 ++, 단항 감소 연산자는 -- 입니다.

    이 연산자의 중요한 내용 중 하나는, 전위식과 후위식을 구분해야 한다는 것입니다.

    연산자 함수가 int operator++()이면 전위식에 해당하고

    연산자 함수가 int operator++(int)이면 후위식에 해당합니다.

    전위식, 후위식에 따라 연산 결과가 매우 달라질 수 있으니 주의가 필요합니다.

     

    예제를 살펴보도록 하겠습니다.

    #include <iostream>
    using namespace std;
    
    class MyClass {
    public:
        MyClass(int value) : my_value(value) {}
    
        operator int() { return my_value; }
    
        int operator++() {
            cout << "Operator++()\n";
            return ++my_value;
        }
    
        int operator++(int) {
            cout << "Operator++(int)\n";
            int data = my_value;
            my_value++;
    
            return data;
        }
    
    private:
        int my_value = 0;
    };
    
    
    
    int main() {
        MyClass a(10);
    
        cout << ++a << "\n";
        cout << a++ << "\n";
        cout << a << "\n";
    
        return 0;
    }
    Operator++()
    11
    Operator++(int)
    11
    12

    자세히 살펴봐야 하는 부분은 int operator++(int) 즉, 후위식입니다.

    값을 증가시키기 전에 미리 백업해두고, 이후 백업된 값을 반환, 자기 자신은 증가시키는 방식으로 구현했습니다.

     

    9. 상속과 연산자 다중 정의 

     

    더보기

    상속 관계에서 기본적으로 모든 연산자는 파생 형식에 자동으로 상속됩니다.

    하지만, 그렇지 않은 연산자가 하나 존재합니다.

     

    예제를 살펴보도록 하겠습니다.

    #include <iostream>
    using namespace std;
    
    class MyClass {
    public:
        MyClass(int param) : my_data(param){}
    
        MyClass operator+(const MyClass& rhs) {
            return MyClass(rhs.my_data + my_data);
        }
        MyClass& operator=(const MyClass& rhs) {
            my_data = rhs.my_data;
    
            return *this;
        }
    
        operator int() { return my_data; }
    protected:
        int my_data = 0;
    };
    
    class MyClassEx : public MyClass {
    public:
        MyClassEx(int param) : MyClass(param){}
    };
    
    
    int main() {
        MyClass a(3), b(4);
    
        cout << a + b << "\n";
    
        MyClassEx c(5), d(6), e(7);
    
        e = c + d; // Line 35 : ERROR
        cout << e << "\n";
    
        return 0;
    }
    E0349 : 이러한 피연산자와 일치하는 "="연산자가 없습니다.
    C2679 : 이항 '=': 오른쪽 피연산자로 'MyClass'형식을 사용하는 연산자가 없거나 허용되는 변환이 없습니다.

    단순 대입 연산자가 바로 그 한가지입니다.

    컴파일 에러를 보면, c + d 부분은 에러가 없는 것을 알 수 있습니다.

    문제는 대입인데,  호출된 연산자 함수의 반환 형식이 MyClass 형식이기 때문에 대입 연산에서 MyClassEx와 형식이 맞지 않기 때문입니다.

     

    이 문제를 해결하기 위한 방법은 두 가지가 있습니다.

    첫번째는 MyClassEx에 연산자 함수를 추가하는 것 입니다.

    Class MyClassEx : public MyClass {
    public:
        MyClassEx(int param) : MyClass(param){}
        
        MyClassEx operator+(const MyClassEx& rhs) {
            return MyClassEx(static_cast<int>(MyClass::operator+(rhs)));
        }
    };

    위와 같은 방식으로 연산자를 정의하는 것으로 위 문제를 해결할 수 있습니다.

     

    하지만 위와 같이 기본 로직을 수정하지 않고, 상위 클래스의 기능에 인터페이스만 맞춰주는 방식이라면아래에 서술할 두 번째 방식이 더 간결할 수 있습니다.

    #include <iostream>
    using namespace std;
    
    class MyClassEx : public MyClass {
    public:
        MyClassEx(int param) : MyClass(param){}
    
        using MyClass::operator+;
        using MyClass::operator=;
    };

    using 키워드를 사용하면 MyClassEx 클래스가 자신만의 연산자를 따로 가진 것 처럼 작동합니다.

     


     

    연산자 오버로딩은 이것 하나만 떼놓고 보면 어려울 것이 없어 보이기도 합니다.

    하지만 차후 다룰 콜백 구조, 함수 포인터, 람다식 등에 빠질 수 없는 내용이며

    그 내용들은 조금 난이도가 있기 때문에

    확실하게 짚고 넘어가도록 합시다.

     

    다음 글은 상속 입니다.

    긴 글 읽어주셔서 감사합니다.

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

    [C++] 가상 함수  (0) 2021.09.05
    [C++] 상속 기본  (0) 2021.08.13
    [C++] 깊은 복사와 얕은 복사  (0) 2021.03.30
    [C++] 클래스 기본 문법 2  (0) 2021.03.12
    [C++] 클래스 기본 문법 1  (0) 2021.02.27

    댓글

Designed by Tistory.