Modern C++ 강의 정리 – 연산자 오버로딩(Operator Overloading)

연산자 오버로딩(Operator Overloading)

+, -, / 등의 primitive operator들에 대해 사용자가 정의한 동작을 수행시킬 수 있다. 예를 들어 어떤 클래스 Food는 + 연산자가 기본으로 없지만 사용자 정의를 통해 + 연산을 하도록 만들 수 있다.

구현은 클래스의 멤버 또는 글로벌 함수로 가능하다. 글로벌 함수로 구현되었을 때는 연산자에 필요한만큼(+의 경우 2개)의 인자를 요구한다. 멤버 함수로 정의된 경우에 자기 자신은 this 포인터를 통해 접근할 수 있다. 따라서 단항 연산자의 경우 멤버 함수로 오버로딩 되었을 때는 인자가 필요없다.

class Integer {
public:
    int value;
    Integer() = default;
    Integer(int value): value(value) {
    }
    Integer operator+(const Integer &i) const {
        return Integer(this->value + i.value);
    }
    Integer & operator++() {
        ++this->value;
        return *this;
    }
};

Integer operator-(const Integer &i1, const Integer &i2) {
 return Integer(i1.value - i2.value);   
}

int main()
{
    Integer i1(1), i2(2);
    std::cout << (i1 + i2).value << std::endl;
    std::cout << (i1 - i2).value << std::endl;
    ++i1;
    std::cout << i1.value << std::endl;
    return 0;
}

위 코드에서 + 연산자는 멤버 함수로 오버로딩 되었고, – 연산자는 전역 함수로 오버로딩 되었다. 연산자 수행 결과로 Integer 객체가 return by value 형식으로 리턴된다. 단항 연산자인 ++도 오버로딩 된것을 확인할 수 있다.

++i와 i++은 사용자 정의 객체에서 같을 수 없다. 전자의 경우 ++연산을 수행 한 후에 객체를 리턴하고, 후자의 경우 객체를 리턴 한 후에 ++연산을 수행한다. 따라서 i++의 경우 복사 생성자를 만들어내기 때문에 전자에 비해 속도가 떨어진다. Primitive Type인 int 같은 경우에는 컴파일러가 자동으로 최적화해주지만 사용자 객체는 그럴 수 없다.


대입 연산자 (Assignment Operator)

이미 존재하는 객체에 다른 객체를 대입할 때가 있다.

class Integer {
public:
    int value;
    Integer() = default;
    Integer(int value): value(value) {
    }
    Integer & operator=(const Integer &i) {
        this->value = i.value;
        return *this;
    }
};

int main()
{
    Integer i1(1), i2(2);
    i1 = i2;
    std::cout << i1.value << std::endl;
    return 0;
}

다음과 같이 대입 연산자를 오버로딩 할 수 있다. 복사 생성자와 마찬가지로 멤버 변수에 포인터가 존재하는 경우에는 기존 멤버 변수에 할당된 공간을 제거하고 새로 할당해야한다. 이 때 원본이 자기 자신인지 체크할 필요가 있다. 원본이 자기자신일 때 멤버 변수에 할당된 공간을 지워버리면 맛이간다.

지난 챕터에서 다룬 것처럼, 복사 대입 연산자가 있으면 복사 생성자 / 소멸자 / 이동 생성자 / 이동 대입 연산자를 모두 구현해야한다.


전역 오버로드 (Global Overloads)
class Integer {
public:
    int value;
    Integer() = default;
    Integer(int value): value(value) {
    }
    Integer operator+(const Integer &i) {
        return Integer(this->value + i.value);
    }
    Integer & operator=(const Integer &i) {
        this->value = i.value;
        return *this;
    }
};

int main()
{
    Integer i1(1), i2(2);
    i1 = i2 + 3; // Works
    i1 = 3 + i2; // Error
    std::cout << i1.value << std::endl;
    return 0;
}

위 코드에서 i1은 되고 i2는 되지 않는 이유는 무엇인가. i2 + 3에 대해서는 Integer의 오버로드 연산자 + 가 실행된다. Integer의 매개변수 생성자 Integer(int value)에 의해 3은 Integer로 변환되어 결국 Integer + Integer의 연산이 호출된다.

반면에 3 + i2에서는 Primitive Type의 연산자 + 가 호출된다. Integer를 int로 바꿀 수 없기 때문에 이 구문은 적합한 연산자를 찾지 못해 에러를 낸다. 이 때 전역 오버로딩을 사용해 다음과 같이 연산자를 정의할 수 있다.

Integer operator +(int x, const Integer &y) {
    Integer temp(x + y.value);
    return temp;
}

Friend Keyword

전역 오버로드 연산자는 객체의 Private 연산자에 접근할 수 없다. 물론 Public한 Access를 줄 수도 있지만 그렇게 하지 않을 때 friend 키워드를 통해 처리할 수 있다.

class Integer {
private:
    int value;
public:
    Integer() = default;
    Integer(int value): value(value) {
    }
    Integer & operator=(const Integer &i) {
        this->value = i.value;
        return *this;
    }
    friend Integer operator +(int x, const Integer &y);
};

Integer operator +(int x, const Integer &y) {
    return Integer(x + y.value);
}


int main()
{
    Integer i1(1), i2(2);
    i1 = 3 + i2;
    return 0;
}

다음의 코드에서 클래스의 멤버 변수 value는 private으로 클래스 외부에서 접근할 수 없다. 하지만 friend 키워드가 있는 함수로는 접근할 수 있다. 연산자 뿐만 아니라 함수나 클래스도 friend로 지정해서 private 멤버에 접근할 수 있게 할 수 있다.


스마트 포인터 (Smart Pointer)

기본 타입의 포인터는 그 할당과 삭제를 수동으로 관리해야한다. 이 때문에 Memory leak과 같은 문제 발생 가능성이 높다. 스마트 포인터의 개념은 어떤 포인터가 더 이상 사용되지 않을 경우 즉시 할당된 공간을 반환하고, 자신을 nullptr로 초기화시켜 위 문제를 해결하도록 하는 것이다.

std::unique_ptr<T> 유니크 포인터는 해당 주소를 단독으로 가리키기 위해 사용된다. 따라서 복사와 대입이 불가능하며, Scope를 벗어나면 자동으로 포인터가 가리키는 주소의 객체도 지워버린다.

int main()
{
    std::unique_ptr<Integer> i(new Integer(1));
    return 0;
}

위와 같이 Integer 객체를 할당하고 그 주소를 unique_ptr로 참조시키면 Main 함수가 종료되면서 Scope가 끝이나고, 자동으로 소멸자가 호출되면서 Integer 객체를 삭제한다.

소유자가 단 하나인만큼 Move 연산은 가능하다.

std::shared_ptr<T> 공유 포인터의 경우 해당 객체를 여러 곳에서 참조할 수 있게 허용한다. 객체의 소멸 시기는 해당 객체의 참조 카운트가 0일 때이다.

void printValue(std::shared_ptr<Integer> i) {
    std::cout << i->value << std::endl;
}

int main()
{
    std::shared_ptr<Integer> i(new Integer(1)); // Reference count = 1
    printValue(i); // Reference count = 2
    return 0;
}

다음과 같이 객체의 참조 카운트가 변화한다. 여기서 참조 카운트는 객체의 원시 포인터를 가리키는 객체가 몇 개인지에 따라 결정된다. 만약 위의 printValue가 포인터가 아닌 레퍼런스를 인자로 받도록 했다면 객체의 참조 카운트가 바뀌지 않았을 것이다.


연산자 오버로딩의 규칙
  • 연산자의 결합 방향(Associativity)와 우선순위(precedence)와 피연산자의 수(arity)는 변하지 않는다.
  • 연산자 함수들은 static일 수 없다.
  • 최소 하나의 인자가 사용자 정의 타입을 가져야한다.
  • 만약 첫번째 피연산자가 원시타입일 경우 전역 오버로드가 필요하다
  • 특정 연산자들은 오버로드 할 수 없다.
  • 새로운 연산자를 정의할 수는 없다.

Explicit 키워드

Explicit 키워드가 붙은 생성자는 컴파일러가 암시적으로 호출할 수 없다. 반드시 코드에 명시적으로 호출 구문이 있어야만 가능하다.

예를 들어 Integer i = 3과 같은 구문에서 매개변수 생성자인 Integer(int)가 존재하면, 컴파일러가 암시적으로 Integer 객체를 3을 가지고 생성해서 복사 생성자에 대입할 수 있다. 하지만 explicit 키워드를 매개변수 생성자에 붙이면 이런 암시적인 변환이 일어나지 못하므로 에러가 발생한다.


변환연산자 (Conversion operator)

Integer 클래스 i1에 대해서 int x = i1과 같은 연산을 어떻게 수행할 수 있을까. Integer 클래스에 변환 연산자를 구현하면 가능하다.

class Integer {
public:
    int value;
    Integer(int value): value(value) {
    }
    operator int() {
        return this->value;
    }
};

int main()
{
    Integer i1(1);
    int x = i1;
}

변환 연산자의 형식은 operator <T>()이며 따로 인자를 받지 않는다.