C++/C++

(작성 중)연산자 다중 정의

Elan 2021. 3. 5. 18:46

 

 

 

연산자(Operator) 오버 로딩 시 주의 사항

 

연산자 함수는 절대로 오류가 발생하면 안 된다.

 

함수는 반환 값을 보고 문제를 확인할 수 있지만, 연산자는 결과를 확인하는 과정이 없다.

 

가령 3+4라는 연산이 실패할 가능성을 생각한다거나 7이 되지 않는 경우를 고려하지 않는다는 것이다.

또한 사용자 코드를 너무 간결하게 작성할 수 있도록 해 준 덕분에 사용자 코드 쪽의 문제점을 찾기 어렵게 할 수 도 있다.

 

주의사항 !!

절대로 논리 연산자들을 다중 정의해서는 안 된다.

이는 심각한 논리적 오류를 사용자에게 떠 넘기게 된다.

 

 


1.    이항 연산자 오버로딩

 

대표적으로 +(더하기)와 -(빼기)를 예로 들어 설명한다.

이 개념을 중심으로 멤버 함수/전역 함수로서의 오버로딩 또한 설명하기로 한다.

class Point
{
private:
    int xpos, ypos;
public:
    Point(int x = 0, int y = 0) : xpos(x), ypos(y)  {}
    
    void ShowPosition() const
    {
        cout << '[' << xpos << ", " << ypos << ']' << endl;
    }
    
    Point operator+(const Point &ref)
    {
        Point pos(xpos + ref.xpos, ypos + ref.ypos);
        return pos;
    }
    
    friend Point operator-(const Point&, const Point&);
};

// 연산자를 전역으로 오버로딩
Point operator-(const Point &pos1, const Point &pos2)
{
    Point pos(pos1.xpos - pos2.xpos, pos1.ypos - pos2.ypos);
    return pos;
}
 
int main(void)
{
    Point pos1(3, 4);
    Point pos2(10, 20);
    Point pos3 = pos1 + pos2; // pos1.operator+(pos2)
    Point pos4 = pos1 - pos2; // operator-(pos1,pos2)
 
    pos3.ShowPosition();
    pos4.ShowPosition();
 
    return 0;
}

위 코드는 +를 멤버 함수로서 오버로딩하고 -를 전역 함수로서 오버로딩한 것이다.

둘의 차이는 무엇일까?

 

1) 멤버 함수로서의 + 연산자 오버로딩

 

pos1+pos2가 주석에 쓰인 것처럼 pos1.operator+(pos2)로 해석된다.

즉, pos1 객체의 함수인 operator+를 호출하고, 함수 인자로 pos2 객체를 전달한 것이다.

 

참고로 멤버 함수로서의 오버로딩은 피연산자의 순서가 정해져 있다는 점에서

자료형이 다를 경우 교환 법칙을 성립시킬 수 없다.

따라서 이를 위해선 전역 함수를 사용해야 한다.

 

2) 전역 함수로서의 - 연산자 오버로딩

 

pos1-pos2가 주석에 쓰인 것처럼 operator-(pos1, pos2)로 해석된다.

전역 함수인 operator-에 인자로 pos1과 pos2 객체를 전달하는 것이다.

 

반환 값은 복사 생성자를 통해 pos3, pos4에 각각 복사된다.

 

여기서 주목해야 할 것이 있는데 전역 함수로서 오버로딩한 operator-가

Point 클래스 내부에 friend로 선언되어 있다는 것이다.

이는 Point 클래스의 private 멤버를 사용하기 위함인데, 

만약 private 멤버를 사용할 필요가 없다면 friend로 선언한 문장은 지워도 아무 상관없다.

 

 

 

 


2.    << ,   >> 연산자 오버로딩

 

cout과 cin을 사용할 때 볼 수 있는 연산자이다.

 

std라는 namespace내부에 iostream 헤더 파일이 있고,

그 안에 istream 클래스와 ostream 클래스가 있다.

이 클래스를 기반으로 만들어진 객체가 ostream 객체 cout이고 istream객체 cin이다.

 

따라서 << 와 >> 연산자를 오버로딩한다는 의미는

ostream/istream 클래스에 정의되어 있는 연산자를 오버로딩한다는 의미와 같다.

 

이 연산자들의 매개변수가 각각의 클래스에 이미 정의된 타입의 매개변수라면 상관없지만,

지금은 Point 객체를 매개변수로 전달할 것이기 때문에 멤버 함수로 오버로딩 하려면 매개변수를 건드려야 한다.

이는 불가하므로 전역 함수로서의 오버로딩은 필연적인 것이 된다.

이제 예제를 살펴보자.

 

class Point
{
private:
    int xpos, ypos;
public:
    Point(int x = 0, int y = 0) : xpos(x), ypos(y)  { }
    
    void ShowPosition() const
    {
        cout << '[' << xpos << ", " << ypos << ']' << endl;
    }
 
    friend ostream& operator<<(ostream&, const Point&);
    friend istream& operator>>(istream&, Point&);
};
 
ostream& operator<<(ostream& os, const Point &pos)
{
    os << '[' << pos.xpos << ", " << pos.ypos << ']' << endl;
    return os;
}

istream& operator>>(istream& is, Point &pos)
{
    is >> pos.xpos;
    is >> pos.ypos;
 
    return is;
}

int main(void)
{
    Point pos1;
    cout << "x, y 좌표 순으로 입력: ";
    cin >> pos1;
    cout << pos1;
 
    return 0;
}

전역함수로서 오버로딩하고 있는 >>연산자의 Point 타입 인자에 const가 붙지 않은 이유는?

: 입력을 받아 값을 변경시켜야 하기 때문이다. 

 

또한 각 함수의 첫번째 인자인 istream과 ostream의 타입이 레퍼런스로 되어있는 이유는?

: istream/ostream이 복사 불가능하기 때문이다.

  또한 << 와 >> 연산을 연속적으로 하기 위해선 레퍼런스를 사용해야 한다.

 

 


3.    대입 연산자(=) 오버로딩

 

대입 연산자는 복사생성자와 마찬가지로 디폴트 대입연산자가 존재한다.

 

멤버간 복사는 얕은 복사를 하며 동적 할당의 경우 깊은 복사를 직접 정의해야 한다.

 

복사생성자와 똑같이 깊은 복사에 대해서만 알아서는 안된다.

 

다른 함정이 숨겨져 있기 때문에 그것에 대해서 확실하게 알아야 한다

class Person
{
private:
    char *name;
    int age;
 
public:
    Person(char *myname, int myage) :age(myage)
    {
        name = new char[strlen(myname) + 1];
        strcpy(name, myname);
    }
    ~Person()
    {
        delete[] name;
        cout << "소멸자 호출" << endl;
    }
};
 
int main(void)
{
    Person man1("박지성", 29);
    Person man2("김연아", 30);
 
    man2 = man1; // default 대입 연산자 호출
 
    return 0;
}

Person에서 복사생성자와 대입연산자를 정의해주지 않았기 때문에

man2 = man1 부분에서 default 복사 생성자와 default 대입연산자가 호출된다.

 

이로 인해 man1은 얕은 복사(Shallow Copy)가 된다.

 

 

이때 2가지 잠재적 문제가 생긴다.

 

첫번째, man1과 man2가 name의 주소를 공유하는 문제가 생긴다.  (소멸자의 dangling pointer 문제 )

 

두번째, man2의 name이 참조하고 있던 "김연아"와의 연결이 끊긴다. ( 메모리 누수 문제 )

 

먼저 첫번째 문제를 알아보자.

man2와 man1의 소멸자가 순서되로 호출될 경우

man2의 멤버 변수 name이 가리키고 있는 "박지성"에 대한 메모리가 해제된다.

이어서 man1의 소멸자에서 name이 가리키고 있는 메모리에 대한 해제를 실행한다.

하지만 man2와 man1의 name이 가리키고있는 대상이 동일하기 때문에

man2에서 먼저 해제 시켜버린 메모리는 dangling pointer가 되고,

man1은 dangling pointer에 대한 메모리 해제를 요청하게 되면서 undefined behaviour가 된다.

 

이 문제들을 해결하기 위해선 Person 클래스의 대입 연산자 또는 소멸자를 오버로딩 해주어야 한다.

Person& operator=(const Person& ref)
{
    delete[] name;
    age = ref.age;
    name = new char[strlen(ref.name) + 1];
    strcpy(name, ref.name);
    return *this;
}

먼저 대입되는 쪽(this)의 name이 가리키는 메모리를 해제 시키고,

깊은 복사(Deep Copy)를 진행한다.

복사생성자와의 다른 점은 반드시 메모리를 처음에 해제시켜줘야 한다는 것이다.

 

대입연산자는 객체 상속 시에도 중요하다.

 

Base 클래스를 상속하는 Derived 클래스가 있다고 하자.

class Base { ... }
class Derived : public Base { ... }

 

아래와 같이 2개의 Derived 객체를 선언하고 대입연산을 수행할 때

Derived d1;
Derived d2;

d1 = d2;

만약 Derived 클래스에 대입연산자가 정의되어 있지 않다면?

 

default 대입연산이 수행 될 것이다.

Base의 default 대입연산이 수행되고, 이어서 Derived의 대입연산이 수행될 것이다.

 

그러나 Derived 클래스에서 대입연산자를 오버로딩 했다면,

상위 객체 Base의 default 대입연산이 자동으로 호출되지 않고

하위 객체 Derived의 대입연산자만 호출된다.

 

그러므로 포인터 타입의 멤버변수를 가지고 있는 class의 상속이 있을 경우

대입연산자를 오버로딩 해주어야 한다.

 

마지막으로 이니셜라이저의 초기화 방식이 몸체에서의 초기화 방식보다 빠른 이유를 설명하고자 한다.

이니셜라이저의 동작 원리는 선언과 동시에 초기화가 되는 방식으로, 복사 생성자만 호출된다.

그러나 몸체는 선언과 초기화를 따로 진행하기 때문에, 선언할 때 생성자가 호출되고 초기화할 때 대입 연산자가 호출된다.

따라서 이니셜라이저의 초기화 방식이 약간의 성능향상이 더 된다는 것이다. 알고만 있자.

 

 


4.     Index 연산자([ ]) 오버로딩

 

C의 기본 배열은 경계검사를 하지 않는다는 심각한 취약점을 가지고 있다.

C++에선 이 취약점을 인덱스 연산자 오버로딩을 통해 극복할 수 있다.

 

int operator[] (int idx) {...}

인덱스 연산자는 위와 같이 오버로딩 한다.

 

해당 클래스의 멤버함수인 operator[]를 사용할 때는 arrayObject[2] 과 같이 사용한다.

하지만 arrayObject.operator[] (2)로 해석이 된다.(실제로 코드상에서 이런식으로 사용해도 문제 없이 동작한다)

 

이 오버로딩 함수안에서 경계검사를 진행하여 안전성을 확보하는 것이 중요하다.

만약 할당한 메모리를 index 연산자를 이용하여 배열처럼 사용하고자 할때

할당된 메모리 범위를 넘어서는 접근이 발생할 경우 이를 막아주어야한다.

class IntArr {
private:
	int* arr;
	int len;
public:
	IntArr() = delete;
	IntArr(const IntArr& _intArr) = delete;
	IntArr(int _len)
	{
		if (_len > 0) {
			arr = new int[_len];
			len = _len;
		}
		arr = nullptr;
		len = -1;
	}
	explicit IntArr(int* _arr, int _len) {
		if (_arr != nullptr && _len > 0)
		{
			arr = _arr;
			len = _len;
		}
		else {
			throw std::runtime_error("_arr should not be nullptr");
		}
	}

	~IntArr() {
		delete arr;
	}
	int& operator[](int _idx) {
		if (_idx < 0 || _idx >= len)
			throw std::out_of_range("_idx exceeded the length of the arr");
		return arr[_idx];
	}

	IntArr& operator=(const IntArr& _intArr) = delete;
};

int main()
{
	int* arr = new int[5]{ 1,2,3,4,5 };

	IntArr arr1(arr, 5);
	int num = arr1[4];
	int num2 = arr1[5]; // exception : std::out_of_range
	std::cout << num;// 5
}

Index연산자의 오버로딩은 따로 클래스를 정의하여 오버로딩하는데.

 


5.   new/delete 연산자 오버로딩

 

이 오버로딩은 다른 연산자 오버로딩과 많이 다르며 꽤 난이도가 있으므로 주의깊게 살펴볼 필요가 있다.

먼저 new가 하는 역할에 대해 살펴보자.

 

1) 메모리 공간의 할당

2) 생성자의 호출

3) 할당하고자 하는 자료형에 맞게 반환된 주소 값의 형 변환 (malloc과의 차이)

 

이 3가지 과정을 거쳐 객체가 생성되는데 이 연산자를 오버로딩 할 경우. 컴파일러가 2,3번은 해주기 때문에

1번 역할만 오버로딩하면 된다. 또한 이 형식은 이미 약속이 되어 있다. 이 형식은 예제로 살펴보기로 하고 delete에 대해서 보자.

 

delete 연산자를 오버로딩하면. 이 함수가 호출되기 전에 소멸자가 호출되기 때문에 이 함수 안에서

메모리 공간의 소멸을 책임져야 한다. 예를 들어서 클래스가 동적할당을 했다고 했을 때 이는 소멸자 안에서

delete로 해제가 되어야 하는데 호출순서가 소멸자->delete인 것이다.

 

위 특징들을 기반으로 예제를 제시하고 설명을 진행하기로 한다.

class Point
{
private:
    int xpos, ypos;
public:
    Point(int x = 0, int y = 0) :xpos(x), ypos(y){}
    friend ostream& operator<<(ostream& os, const Point& pos);
 
    void* operator new(size_t size)
    {
        cout << "operator new : " << size << endl;
        void *adr = new char[size];
        return adr;
    }
 
    void operator delete(void *adr)
    {
        cout << "operator delete ()" << endl;
        delete[] adr;
    }
};
 
ostream& operator<<(ostream& os, const Point& pos)
{
    os << '[' << pos.xpos << ", " << pos.ypos << ']' << endl;
    return os;
}
 
int main(void)
{
    Point *ptr = new Point(3, 4);
    cout << *ptr;
    delete ptr;
    return 0;
}

위에서 오버로딩된 new와 delete의 형태는 약속된 것으로서 변경할 수가 없다.

저렇게 형태를 지정해야 컴파일러가 인식하고 호출하기 때문이다. 이 코드를 컴파일 하면

 

 

다음과 같이 new 연산자에선 할당한 메모리의 크기를 출력하는데 이는 Point 클래스가 가진 int형 변수 2개의

크기, 8바이트라는 것을 알 수 있다.

그런데 여기서 의문이 든다. 전달하지도 않은 숫자를 컴파일러는 어떻게 알았는가?

또한 new와 delete는 객체가 생성되야 호출할 수 있는 멤버함수인데 어떻게 호출하는가?

 

이는 new와 delete가 static 함수로 간주되기 때문이다. 따라서 컴파일러는 객체를 생성하기 전에 Point 클래스가

어느정도의 크기를 갖고 있는지 미리 알아 그것을 size 매개변수에 넘겨주는 것이다.

또한 객체 생성 이전에 호출 가능한 이유도 static이라는 것으로 해결이 된다.

 

그런데 여기서 한가지 의문이 들었다. 왜 전역함수로서의 오버로딩은 하지 않을까? 그 이유는 C++ 레퍼런스에서 찾을 수 있었는데,

new연산자는 new라는 헤더파일에 포함되어 있는데 이는 std가 아닌 global namespace에 정의되어 있다.

이 말은 new 자체가 전역함수라는 뜻. 따라서 따로 전역함수를 선언하면 컴파일러는 이 함수가 이미 정의되어 있다는

에러를 발생시키게 되는 것이다.

 

마지막으로 배열의 경우는 어떻게 오버로딩할까? 단순히 new/delete에 []를 붙여주면 간단하게 해결된다.

동작방식은 일반 new/delete와 동일하기 때문이다. 아! 그리고 new로 char 타입의 메모리를 할당하는 이유는

컴파일러가 메모리의 크기를 바이트 단위로 계산하기 때문이다. char는 1바이트기 때문에 계산하기 쉽다.

 

 


6.   포인터 연산자 오버로딩

 

포인터 연산자란 *,-> 이 2가지를 뜻하며 하나의 예제로 마무리한다.

 

class Number
{
private:
    int num;
public:
    Number(int n) :num(n){}
    void ShowData() { cout << num << endl; }
 
    Number* operator->()
    {
        return this;
    }
 
    Number& operator*()
    {
        return *this;
    }
};
 
int main(void)
{
    Number num(20);
    num.ShowData();
 
    (*num) = 30;
    num->ShowData();
    (*num).ShowData();
 
    return 0;
}

1) (*num) = 30;

 

이 연산이 이해되지 않을 수 있지만 임시 객체라는 것으로 이해하면 된다.

30은 Number(30)이라는 임시객체로 변환되며 대입연산자를 통해서 (*num)의 값이 변경되는 것이다.

본론으로 돌아와서 이 연산은 다음과 같이 해석된다.

(num.operator*()) = 30;

객체 자신을 리턴하기 때문에 (*num).ShowData()와 같은 문장도 가능하다.

 

2) num->ShowData();

 

num.operator->() ShowData()와 같이 이해되는 것이 일반적이지만 불가능하므로,

num.operator->()->ShowData()와 같이 이해된다. -> 연산자는 객체 자신의 주소값을 리턴하기 때문.

 

이 연산자들의 오버로딩은 스마트 포인터에 사용되며 이제 스마트 포인터를 이해해보자.

 

 

 


7.     스마트 포인터

 

스마트 포인터는 말 그대로 똑똑한 포인터로서 원래 어떠한 포인터 변수로 하여금 메모리를 할당하면,

메모리 누수로 이어지지 않게 반드시 해제해주어야 한다.

따라서 개발자는 delete를 이용해서 명시적으로 해제해주어야 하는데. 개발자도 사람이기 때문에 실수로 하지 않을 수가 있다.

이러한 취약점을 보완하여 raw pointer를 class로 wrap한 것이 바로 스마트 포인터이다.

자세한 설명은 다음을 참조하자.

 

What is a smart pointer and when should I use one?

 

라이브러리에서 이미 정의된 여러가지 스마트 포인터를 제시하고 있지만 여기선 예제를 들어 일단 이해하기로 한다.

 

class SmartPtr
{
private:
    Point *posptr;
public:
    SmartPtr(Point *ptr) :posptr(ptr){}
    Point& operator*() const
    {
        return *posptr;
    }
    Point* operator->() const
    {
        return posptr;
    }
    ~SmartPtr()
    {
        delete posptr;
    }
};
 
int main(void)
{
    SmartPtr sptr1(new Point(1, 2));
    SmartPtr sptr2(new Point(2, 3));
 
    cout << *sptr1;
    cout << *sptr2;
 
    sptr1->SetPos(10, 20);
    sptr2->SetPos(30, 40);
 
    cout << *sptr1;
    cout << *sptr2;
 
    return 0;
}

6번에서 설명한 포인터 연산의 오버로딩이 여기 사용된다. 스마트 포인터이기 때문에 기본적으로

포인터 연산이 가능해야 하기 때문이다.

이 스마트 포인터의 의미는 하나의 클래스를 만드는 것을 통해서 모든 포인터 연산과 메모리의 소멸까지 제어하기 때문에

위에서 말한 것처럼 안전성이 확보된다. 여기선 이것까지만 이해하도록 하자.

 


8.   () 연산자 오버로딩과 Functor(펑터)

 

함수 호출 연산자인 () 연산자 또한 오버로딩 가능한데, 이 연산자를 오버로딩하는 이유는 무엇일까?

그 이유는 펑터라고 불리는 기법을 사용하기 위함이다.

펑터란 함수 오브젝트라고도 불리는데 객체를 함수처럼 사용하는 것을 뜻한다.

이 펑터를 사용하는 이유는? 함수 또는 객체의 동작방식에 유연함을 제공할 때 사용된다.

 

본 개념을 이해할 때는 책의 예제가 아닌 스택오버플로우의 예제를 사용하겠다.

 

class Matcher
{
   int target;
   public:
     Matcher(int m) : target(m) {}
     bool operator()(int x) { return x == target;}
}
 
Matcher Is5(5);
 
if (Is5(n))    // same as if (n == 5)
{ ....}

Matcher는 ()연산자가 오버로딩된 Functor이고 생성할 때 target을 5로 초기화해준 상태이다.

이 때 주목해서 봐야 할 것은 if문이다. 여기선 Functor를 사용해서 n==5의 기능을 실현시켰다.

이것이 Functor의 핵심적인 역할이며 그 원리는 바로 Functor가 클래스인 것에서부터 온다.

 

클래스는 상태(state)를 가질 수 있기 때문에 굉장히 flexible 하며 generic하게 쓰일 수 있다.

함수와의 다른 점은 변수와 함수를 동시에 wrap하는 것이 가능하기 때문인 것 같다. 아직 나도 정확하게 이해가 안된다.

컴파일러가 Functor를 인라인으로 처리한다는 말도 있고 함수의 지역변수와 클래스의 멤버변수가 여기서 어떻게 다른것인지...

물론 클래스의 경우 encapsulation이 되는 장점은 있지만 정확히는 이해가 안되기 때문에 나중에 아래 링크를 참조하기로 하자.

 

C++ Functors - and their uses

 

 

 

참고

https://sexycoder.tistory.com/13

 

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

 

operator overloading - cppreference.com

Customizes the C++ operators for operands of user-defined types. [edit] Syntax Overloaded operators are functions with special function names: operator op (1) operator type (2) operator new operator new [] (3) operator delete operator delete [] (4) operato

en.cppreference.com

 

 

 

 

 

 

 

 

 


9.     형변환 연산자

 

class Area {
public:
	Area(const int _area) : area(_area) {}

	operator int() 
    {
		return area;
	}
private:
	int area;
};

class Square {
public:
	Square(const int _length) :length(_length) {	}
	operator Area() 
    {
		return length * length;
	}
private:
	int length;
};

int main()
{
	Square square1(5);
	Area area = square1;
	std::cout << "Area : " << area << std::endl;
	std::cout << "Area : " << (Area)square1 << std::endl;
}

위와 같은 연산자들을 class에서도 사용할 수 있도록 함수로 정의할 수 있다.

 

형 변환 연산자는 함수 시그니처에 반환 타입을 기재하지 않는 특징이 있다.

 

Area area = square1 부분은 묵시적으로 형변환이 일어나는 부분이다.

컴파일러가 형변환 처리하여 Area area(25) 이것과 같이 동작한다고 보면 된다.

실제로 Area area = 25 와 같이 작성해도 마찬가지로 동작한다.

 

또한 std::cout << 부분에 Area객체를 전달해도 컴파일러가 형변환 연산자를 자동으로 형변환하여

int형으로 반환해주는 것이다.


만약 Area의 형변환 연산자 앞에 explict을 붙여주면 어떻게 될까?

std::cout<< 부분에서 자동 형변환을 막아버려서 컴파일 에러가 발생할 것이다.

 

그렇다면 Square의 형변환 연산자에 explicit을 붙이면 어떻게 될까?

Area area = square1 부분에서 Square를 Area로 자동 형변환을 해주지 않아서

컴파일 에러가 발생할 것이다.

 
 

따라서 값타입을 반환하는 형변환 연산자를 정의해 줄때에는 주의해야한다.

컴파일러가 알게 모르게 묵시적 형변환을 해버리기 때문이다.

실수로 다른 객체를 대입해버렸는데도 문제 없이 컴파일되는 불상사가 발생할 수 있기 때문이다.