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

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

by 별준 2022. 2. 20.

References

  • Professional C++

Contents

  • 함수 호출 연산자 오버로딩
  • 역참조 연산자 오버로딩
  • 변환 연산자
  • 메모리 할당/해제 연산자 오버로딩
  • 사용자 정의 리터럴 연산자 오버로딩

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

지난 포스팅에 이어 연산자 오버로딩에 대해서 계속 알아보도록 하겠습니다.

 

 


6. 함수 호출 연산자 오버로딩

함수 호출 연산자 operator()도 오버로딩할 수 있습니다. 클래스를 정의할 때 operator()를 추가하면 이 클래스의 객체를 함수 포인터처럼 사용할 수 있습니다. 함수 호출 연산자를 제공하는 클래스의 객체를 함수 객체(function object, or functor)라고 합니다. 이 연산자는 non-static 메소드로 오버로딩해야 합니다.

아래 코드를 통해 operator()를 오버로딩하는 예를 간단히 살펴보겠습니다.

class FunctionObject
{
public:
    int operator() (int param); // Function call operator
    int doSquare(int param);    // Normal method
};

int FunctionObject::operator() (int param)
{
    return doSquare(param);
}

int FunctionObject::doSquare(int param)
{
    return param * param;
}

이렇게 정의한 함수 호출 연산자는 다음과 같이 사용할 수 있습니다.

int x{ 3 }, xSquared, xSquaredAgain;
FunctionObject square;
xSquared = square(x);               // Call the function call operator
xSquaredAgain = square.doSquare(x); // Call the normal method

얼핏 보면 함수 호출 연산자를 사용하는 코드가 조금 어색해 보입니다. 클래스의 객체를 만드는 특수한 메소드를 굳이 이렇게 함수 포인터처럼 보이는 형태로 정의할 필요가 있을까요? 그냥 일반 메소드나 함수로 구현하는게 더 좋아 보이기는 합니다. 이렇게 일반 메소드 대신 함수 객체로 만들면 좋은 가장 확실한 장점은 함수 객체를 함수 포인터로 표현해서 다른 함수에 콜백 함수로 전달할 수 있다는 점입니다.

 

또한 전역 함수보다 함수 객체로 만들면 다음과 같은 장점들이 있습니다.

  • 함수 호출 연산자를 여러 번 호출하더라도 객체의 데이터 멤버를 통해 정보를 지속적으로 유지할 수 있습니다. 예를 들어 함수 호출 연산자를 호출할 때마다 누적된 숫자의 합을 함수 객체에 유지할 수 있습니다.
  • 데이터 멤버를 설정하는 방식으로 함수 객체의 동작을 변경할 수 있습니다. 예를 들어 함수 호출 연산자에 지정한 인수를 데이터 멤버의 값과 비교하는 함수 객체를 만들 수 있습니다. 이때 데이터 멤버를 설정할 수 있다면 비교 방식을 마음대로 변경할 수 있습니다.

 

물론 방금 언급한 장점들은 전역 변수나 static 변수로도 얼마든지 구현할 수 있습니다. 하지만 함수 객체를 활용하면 코드가 훨씬 깔끔합니다. 게다가 전역 변수나 static 변수는 멀티 스레드 어플리케이션에서 문제를 발생시킬 수 있습니다.

 

메소드 오버로딩 규칙에 따르면 클래스에 operator()를 얼마든지 추가할 수 있습니다. 예를 들어 다음과 같이 FunctionObject 클래스에 std::string_view 타입의 인수를 받는 operator()를 추가할 수도 있습니다.

int operator() (int param);
void operator() (std::string_view str);

다차원 배열의 인덱스를 지정할 때도 함수 호출 연산자를 활용할 수 있습니다. operator()를 단순히 operator[]처럼 동작하도록 작성하되 인덱스를 하나 이상 받도록 만들면 됩니다. 단 인덱스가 []가 아닌 ()으로 묶어야 합니다.

(ex, myArray(3, 4) = 6)

 


7. 역참조 연산자 오버로딩

역참조 연산자(*, ->, ->*)도 오버로딩할 수 있습니다. ->*는 우선 무시하고, 익숙한 *와 -> 연산자의 기본 의미부터 살펴보겠습니다. *는 포인터가 가리키는 값에 직접 접근하는 역참조 연산자입니다. 반면 ->는 * 연산자뒤에 멤버를 지정하는 .(dot) 연산자를 붙인 것을 축약한 형태입니다. 예를 들어 두 문장은 서로 의미가 같습니다.

SpreadsheetCell* cell{ new SpreadsheetCell };
(*cell).set(5); // Dereference + member selection
cell->set(5);   // Shorthand arrow dereference and member selection together

클래스를 직접 정의할 때 역참조 연산자를 오버로딩하면 그 클래스의 객체를 포인터처럼 다룰 수 있습니다. 이 기능은 주로 스마트 포인터를 구현할 때 사용합니다. 또한 반복자(iterator)를 다룰 때도 유용합니다. 그래서 표준 라이브러리는 이 기능을 상당히 많이 활용합니다. 여기서는 간단한 스마트 포인터 클래스 템플릿에서 역참조 연산자를 오버로딩하는 데 관련된 기본 메커니즘을 살펴보도록 하겠습니다.

보통 스마트 포인터를 직접 정의하는 것보다 표준 스마트 포인터 클래스(unique_ptr, shared_ptr)을 활용하는 것이 바람직합니다. 아래서 살펴볼 예제는 단순히 역참조 연산을 오버로딩하는 방법을 보여주기 위한 것입니다.

 

예제에서 활용할 스마트 포인터 클래스 템플릿의 정의는 다음과 같습니다. 아직 역참조 연산자는 오버로딩하지 않았습니다.

template<typename T>
class Pointer
{
public:
    Pointer(T* ptr) : m_ptr{ ptr } {}
    virtual ~Pointer() {
        delete m_ptr;
        m_ptr = nullptr;
    }
    // Prevent assignment and pass by value
    Pointer(const Pointer& src) = delete;
    Pointer& operator=(const Pointer rhs) = delete;

private:
    T* m_ptr{ nullptr };
};

여기서는 스마트 포인터를 최대한 간단히 정의했습니다. 일반 포인터로 저장했다가 스마트 포인터가 소멸될 때 그 포인터가 가리키던 공간을 해제하기만 합니다. 구현 코드도 간단합니다. 생성자에 일반 포인터를 받아서 클래스의 데이터 멤버에 저장합니다. 소멸자는 포인터가 참조하던 공간을 해제합니다.

 

이렇게 정의한 스마트 포인터 클래스 템플릿은 다음과 같이 사용할 수 있습니다.

Pointer<int> smartInt{ new int };
*smartInt = 5; // Dereference the smart pointer
std::cout << *smartInt << std::endl;

Pointer<SpreadsheetCell> smartCell{ new SpreadsheetCell };
smartCell->set(5); // Dereference and member select the set() method.
std::cout << smartCell->getValue() << std::endl;

예제에서 볼 수 있듯이 이 클래스에 대해 operator*와 operator-> 연산자를 구현해야 합니다.

 

7.1 operator* 구현

일반적으로 포인터를 역참조한다는 말은 포인터가 가리키던 메모리에 접근한다는 의미입니다. 포인터가 가리키는 메모리가 int와 같은 기본 타입 값을 담도 있다면 그 값을 직접 변경할 수 있습니다. 반면 포인터가 가리키는 메모리가 객체와 같이 복합 타입으로 된 대상을 담고 있다면 그 안에 있는 데이터 멤버나 메소드에 접근하기 위해서는 '.'연산자를 사용해야 합니다.

 

이렇게 하려면 operator*가 레퍼런스를 리턴해야 합니다. 따라서 Pointer 클래스에 다음과 같이 정의합니다.

template<typename T>
class Pointer
{
public:
    // .. 나머지 코드 생략
    T& operator*() { return *m_ptr; };
    const T& operator*() const { return *m_ptr; };
    // .. 나머지 코드 생략
};

여기서 볼 수 있듯이 operator*는 클래스 내부의 일반 포인터가 가리키던 객체나 변수에 대한 레퍼런스를 리턴합니다. 인덱스 연산자를 오버로딩할 때처럼 이 메소드를 const와 non-const 버전을 모두 제공해서 const 레퍼런스와 non-const 레퍼런스를 리턴하도록 정의하는 것이 좋습니다.

 

7.2 operator-> 구현

화살표 연산자(>)를 구현하는 방법은 조금 까다롭습니다. 화살표 연산자를 적용한 결과는 반드시 객체의 멤버나 메소드여야 합니다. 그런데 이렇게 구현하려면 operator*를 실행한 뒤 곧바로 operator.을 호출하게 만들어야 합니다. 하지만 C++에서는 operator.을 오버로딩할 수 없습니다. 이렇게 제한하는 이유는 프로토타입 하나만으로 임의의 멤버나 메소드를 선택하게 만들 수 없기 때문입니다. 그래서 C++은 operator->를 예외로 취급합니다.

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

smartCell->set(5);

C++은 위 문장을 다음과 같이 해석합니다.

(smartCell.operator->())->set(5);

이처럼 C++은 오버로딩한 operator->에서 리턴한 값에 다른 operator->를 적용합니다. 그래서 다음과 같이 반드시 포인터로 리턴하게 오버로딩해야 합니다.

template<typename T>
class Pointer
{
public:
    // .. 나머지 코드 생략
    T* operator->() { return m_ptr; }
    const T* operator->() const { return m_ptr; };
    // .. 나머지 코드 생략
};

 

7.3 operator.* 과 operator->*

C++은 클래스의 데이터 멤버와 메소드에 대한 주소를 받아서 포인터를 만드는 기능을 정식으로 제공합니다. 하지만 객체를 거치지 않고서는 non-static 메소드나 데이터 멤버를 호출하거나 접근할 수 없습니다. 클래스에서 데이터 멤버와 메소드를 제공하는 목적은 객체마다 데이터 멤버와 메소드를 따로 갖게 하기 위해서입니다. 따라서 포인터를 통해 데이터 멤버에 접근하거나 메소드를 호출하려면 반드시 객체의 문맥 안에서 포인터를 역참조해야하 합니다.

 

다음 예를 통해 구체적인 방법을 알아보겠습니다.

SpreadsheetCell myCell;
double (SpreadsheetCell::*methodPtr) () const = &SpreadsheetCell::getValue;
std::cout << (myCell.*methodPtr)() << std::endl;

이 코드를 보면 .* 연산자로 메소드 포인터를 역참조하는 방식으로 메소드를 호출했습니다. 또한 객체 자체는 없고, 객체에 대한 포인터만 있을 때는 다음과 같이 operator->*로 메소드를 호출할 수도 있습니다.

SpreadsheetCell* myCell{ new SpreadsheetCell{} };
double (SpreadsheetCell::*methodPtr) () const = &SpreadsheetCell::getValue;
std::cout << (myCell->*methodPtr)() << std::endl;

 

operator.와 마찬가지로 operator.*도 오버로딩할 수 없습니다. operator->*는 오버로딩할 수 있지만 구현 방법이 복잡할 뿐만 아니라 이렇게 포인터로 데이터 멤버나 메소드를 접근할 수 있다는 사실을 알고 있는 프로그래머도 거의 없기 때문에 이렇게 작성할 필요가 거의 없습니다. 표준 라이브러리에서 제공하는 std::shared_ptr 템플릿도 operator->*를 오버로딩하지 않습니다.

 


8. 변환 연산자 구현

다음의 두 문장을 살펴보겠습니다.

SpreadsheetCell cell{ 1.23 };
double d1{ cell }; // compile error!

SpreadsheetCell을 double로도 표현할 수 있기 때문에 이렇게 double 타입 변수에 대입해도 문제없다고 생각할 수 있지만, 막상 이렇게 작성하면 에러가 발생합니다. SpreadsheetCell을 double 타입으로 변환하는 방법을 컴파일러가 모르기 때문입니다. 이를 해결하기 위해서 다음과 같이 수정해봅시다.

double d1{ (double)cell }; // still compile error!

이렇게 해도 문제를 해결할 수 없습니다. 여전히 컴파일러는 SpreadsheetCell을 double로 변환하는 방법을 모릅니다.

 

앞에 나온 문장처럼 대입하고 싶다면 컴파일러에 구체적인 방법을 알려주어야 합니다. 다시 말해 SpreadsheetCell을 double로 변환하는 변환 연산자를 구현해야 합니다. 여기에 사용할 변환 연산자의 프로토타입은 다음과 같습니다.

operator double() const;

이 함수의 이름은 operator double 입니다. 함수 이름 안에 리턴 타입이 표현됐기 때문에 리턴 타입을 지정할 필요는 없습니다. 그리고 이 연산자를 호출할 객체는 변경되지 않기 때문에 const로 지정했습니다. 이 연산자의 구현은 다음과 같습니다.

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

이렇게만 해도 SpreadsheetCell을 double로 변환하는 연산자를 구현할 수 있습니다. 이제 다음과 같이 작성해도 컴파일 에러가 발생하지 않습니다.

SpreadsheetCell cell{ 1.23 };
double d1{ cell }; // works as expected

 

다른 타입에 대한 변환 연산자도 이와 똑같이 작성합니다. 예를 들어 SpreadsheetCell의 std::string 변환 연산자를 다음과 같이 구현할 수 있습니다.

SpreadsheetCell::operator std::string() const
{
    return doubleToString(getValue());
}

그러면 이제 SpreadsheetCell을 string으로 변환할 수 있지만, string을 받는 생성자 때문에 다음과 같이 코드를 작성하면 컴파일 에러가 발생합니다.

SpreadsheetCell cell{ 1.23 };
std::string str = cell;

이런 경우 uniform initialization 대신 일반 할당 문법을 사용하거나 명시적으로 static_cast()를 사용하면 됩니다.

std::string str1 = cell;
std::string str2{ static_cast<std::string>(cell) };

 

8.1 Operator auto

명시적으로 변환 연산자가 리턴하는 타입을 지정하는 대신, auto를 사용하여 컴파일러가 이를 추론하도록 할 수 있습니다. 예를 들어, SpreadsheetCell의 double 변환 연산자는 다음과 같이 작성할 수 있습니다.

operator auto() const { return getValue(); }

한 가지 주의할 점은 auto 리턴 타입 추론이 있는 메소드의 구현은 사용자가 볼 수 있어야 합니다. 따라서 이 예는 클래스 정의에서 바로 구현합니다.

 

또한, auto는 레퍼런스와 const를 자동으로 잘라냅니다. 그래서 operator auto가 타입 T의 레퍼런스를 리턴한다면 추론된 타입은 값으로 리턴되어 결국은 복사가 발생합니다. 필요하다면 명시적으로 레퍼런스와 const를 추가할 수 있습니다.

operator const auto&() const { /* ... */ }

 

8.2 Solving Ambiguity Problems with Explicit Conversion Operators

SpreadsheetCell 객체에 대한 double 변환 연산자를 추가하면 모호함이 발생할 수 있습니다.

예를 들어 다음의 코드를 살펴보겠습니다.

SpreadsheetCell cell{ 6.6 };
double d1{ cell + 3.3 }; // does not compile if operator double() is defined

이렇게 작성하면 컴파일 에러가 발생합니다. 그 이유는 컴파일러가 cell을 operator double()에 적용해서 double 덧셈으로 처리할지, 아니면 3.3을 double 생성자에 적용해서 SpreadsheetCell로 변환한 뒤 SpreadsheetCell 덧셈으로 처리할지 결정할 수 없기 때문입니다. operator double()을 구현하지 전에는 그냥 3.3을 double 생성자로 전달해서 SpreadsheetCell로 변환한 뒤 SpreadsheetCell 덧셈으로 처리하면 되기 때문에 고민할 일이 없었습니다. 하지만 operator double()이 추가된 후에는 두 가지 옵션이 있는데, 어느 것이 좋은지 판단할 수 없어서 그냥 포기한 것입니다.

 

C++11 이전에는 이런 모호함이 발생하면 생성자 앞에 explicit 키워드를 지정해서 자동 변환할 때 이 생성자를 사용하지 않게 할 수 있었습니다. 하지만 생성자를 explicit으로 지정하는 것은 바람직하지 않습니다. double을 SpreadsheetCell로 자동으로 변환하는 기능이 필요할 때가 많기 때문입니다. C++11부터는 생성자 대신 double 변환 연산자를 explicit으로 선언하는 방식으로 해결합니다.

explicit operator double() const;

이렇게 정의하면 다음 코드는 정상적으로 컴파일이 됩니다.

double d1{ cell + 3.3 };

 

8.3 부울 표현식으로 변환

때로는 객체를 부울 표현식에서 사용하면 좋을 때가 있습니다. 대표적인 예로 다음과 같이 조건문에서 포인터를 사용할 때가 있습니다.

if (ptr != nullptr) { /* Perform some dereferecning action */ }

또는 이 문장을 다음과 같이 줄일 수 있습니다.

if (ptr) { /* Perform some dereferecning action */ }

아니면 다음과 같이 조건을 반대로 설정하기도 합니다.

if (!ptr) { /* Perform some dereferecning action */ }

현재는 앞서 정의한 Pointer 스마트 포인터 클래스 템플릿을 사용하면 위의 코드들은 모두 컴파일 에러가 발생합니다. 그러나, Pointer에 포인터 타입으로 변환하는 연산자를 추가하면 문제없이 쓸 수 있습니다. 그래서 if 문의 조건에 nullptr과 비교하는 문장을 작성할 때뿐만 아니라 그냥 객체만 적어도 자동으로 포인터 타입으로 변환됩니다. 이러한 변환 연산자는 주로 void* 타입을 사용합니다. 이 포인터 타입을 사용하면 부울 표현식에서 테스트하는 용도 외에는 다르게 활용할 수 없기 때문입니다.

포인터 변환 연산자의 구현 예는 다음과 같습니다.

operator void*() const { return m_ptr; }

이제 다음과 같이 코드를 작성해도 문제없이 컴파일될 뿐만 아니라 의도한 대로 실행됩니다.

void process(Pointer<SpreadsheetCell>& p)
{
    if (p != nullptr) { std::cout << "not nullptr\n"; }
    if (p != NULL) { std::cout << "not NULL\n"; }
    if (p) { std::cout << "not nullptr\n"; }
    if (!p) { std::cout << "nullptr\n"; }
}

int main()
{
    Pointer<SpreadsheetCell> smartCell{ nullptr };
    process(smartCell);
    std::cout << std::endl;

    Pointer<SpreadsheetCell> anotherSmartCell{ new SpreadsheetCell{5.0} };
    process(anotherSmartCell);
}

위 코드를 실행한 결과는 다음과 같습니다.

 

또 다른 방법으로 operator void*() 대신 다음과 같이 operator bool()을 오버로딩해도 됩니다. 어짜피 객체를 부울 표현식에서 사용할 것이기 때문에 직접 bool로 변환하는 것이 낫습니다.

operator bool() const { return m_ptr != nullptr; }

다음과 같은 기존 비교 연산도 문제없이 실행됩니다.

if (p != NULL) { std::cout << "not NULL\n"; }
if (p) { std::cout << "not nullptr\n"; }
if (!p) { std::cout << "nullptr\n"; }

하지만 operator bool()을 사용하면 다음과 같이 nullptr과 비교하는 문장에서는 컴파일 에러가 발생합니다.

if (p != nullptr) { std::cout << "not nullptr\n"; }

nullptr의 타입은 nullptr_t이고 자동으로 0(false)로 변환되지 않기 때문에 컴파일 에러가 발생합니다. Pointer 객체와 nullptr_t 객체를 인수로 받는 operator!= 연산자를 컴파일러가 찾지 못하기 때문입니다. operator!=와 같은 연산자를 Pointer 클래스의 friend로 구현해도 됩니다.

template<typename T>
class Pointer
{
public:
    // .. 나머지 코드 생략
    operator bool() const { return m_ptr != nullptr; }
    friend bool operator!=(const Pointer<T>& lhs, std::nullptr_t rhs);
    // .. 나머지 코드 생략
};

template<typename T>
bool operator!=(const Pointer<T>& lhs, std::nullptr_t rhs)
{
    return lhs.m_ptr != rhs;
}

 

하지만, operator!=를 이렇게 구현하면 다음과 같은 비교는 할 수 없게 됩니다. 어느 operator!=를 사용할지 컴파일러가 결정할 수 없기 때문입니다.

if (p != NULL) { std::cout << "not NULL\n"; }

이 예제를 보면 포인터를 표현하지 않는 객체를 사용할 때와 이렇게 포인터 타입으로 변환하는 것이 맞지 않을 때만 operator bool()를 추가하는 방식으로 구현해야 한다고 생각하기 쉽습니다. 아쉽게도 bool로 변환하는 연산자를 추가하면 이 문제뿐만 아니라 다른 예상치 못한 문제도 발생합니다. 이런 경우가 발생하며니 C++은 bool을 int로 자동으로 변환하는 promotion(승격) 규칙을 적용합니다. 따라서 operator bool()이 정의되었을 때 다음과 같이 작성하면 문제없이 컴파일해서 실행할 수 있습니다.

Pointer<SpreadsheetCell> anotherSmartCell{ new SpreadsheetCell{ 5.0 } };
int i{ anotherSmartCell }; // Converts Pointer to bool to int

하지만 이는 보통 의도한 동작이 아닙니다. 이러한 대입을 막으려면 int, long, long long 등에 대한 변환 연산자를 명시적으로 delete해야 합니다. 하지만 코드가 지저분해집니다. 그래서 대부분 operator bool() 보다는 operator void*()를 선호합니다.

 


9. 메모리 할당/해제 연산자 오버로딩

C++은 메모리 할당과 해제 작업을 원하는 형태로 정의하는 기능을 제공합니다. 이러한 커스터마이즈 작업은 프로그램 전반에 적용하게 만들 수 있고, 클래스 단위로 적용하게 만들 수 있습니다. 이 기능은 조그만 객체들을 여러 차례 할당하게 해제하는 과정에서 발생하기 쉬운 메모리 파편화(memory fragmentation, 메모리 단편화)를 방지하는 데 주로 사용됩니다. 예를 들어 메모리가 필요할 때마다 디폴트 C++ 메모리 할당 기능 대신 고정된 크기의 메모리 영역을 미리 할당해서 메모리 풀 할당자(memory pool allocator)로 만들고 여기서 메모리를 재사용하도록 구현할 수 있습니다. 이번에는 이렇게 메모리 할당과 해제 루틴을 직접 만드는 방법과 이 과정에서 발생하는 여러 가지 이슈를 알아보도록 하겠습니다.

 

9.1 new와 delete의 작동 방식

C++에서 난해한 부분중 하나는 new와 delete의 세부 작동 과정입니다. 예를 들어 다음 코드를 살펴보겠습니다.

SpreadsheetCell* cell{ new SpreadsheetCell{} };

여기서 new SpreadsheetCell()을 new-expression(neew-표현식)이라고 부릅니다. 이 문장은 두 가지 일을 수행합니다. 먼저 operator new를 호출해서 SpreadsheetCell 객체에 대한 메모리를 할당합니다. 그리고 나서 객체의 생성자를 호출합니다. 생성자의 실행이 끝나야 객체에 대한 포인터가 리턴됩니다.

 

delete의 작동 방식도 비슷합니다.

delete cell;

이렇게 작성한 문장은 delete-expression(delete-표현식)이라고 부릅니다. 이 문장을 실행하면 먼저 cell의 소멸자를 호출한 다음 operator delete를 호출해서 cell에 할당된 메모리를 해제합니다.

 

operator new와 operator delete를 오버로딩해서 메모리 할당과 해제 과정을 직접 제어할 수 있다고 했습니다. 그런데 new-표현식과 delete-표현식 자체를 오버로딩할 수는 없습니다. 다시 말해 실제로 메모리를 할당하고 해제하는 과정은 커스터마이즈할 수 있지만 생성자와 소멸자를 호출하는 동작은 변경할 수 없습니다.

 

9.1.1 new-expression과 operator new

new-expression에는 여섯 가지 종류가 있습니다. 각 버전마다 적용되는 operator new가 따로 있습니다. 그중 네 가지(new, new[], new(nothrow), new(nothrow)[])에 대응되는 operator new는 다음과 같으며 모두 <new> 헤더 파일에 정의되어 있습니다.

void* operator new(size_t size);
void* operator new[](size_t size);
void* operator new(size_t size, const std::nothrow_t&) noexcept;
void* operator new[](size_t size, const std::nothrow_t&) noexcept;

 

나머지 두 개는 실제로 객체를 할당하지 않고 기존에 저장된 객체의 생성자를 호출만 하는 특수한 형태의 new-expression입니다. 이를 placement new operators(배치 new 연산자)라 부르며, 일반 변수 버전과 배열 버전이 있습니다. 이 연산자를 이용하면 다음과 같이 기존에 확보된 메모리에서 객체를 생성할 수 있습니다.

void* ptr{ allocateMemorySomehow() };
SpreadsheetCell* cell{ new (ptr) SpreadsheetCell{} };

여기에 대응되는 operator new는 다음과 같습니다. 참고로 C++ 표준에서는 다음의 두 가지 operator neew에 대한 오버로딩은 금지하고 있습니다.

void* operator new(size_t size, void* p) noexcept;
void* operator new[](size_t size, void* p) noexcept;

구문은 조금 어색하지만 이런 기능이 있다는 사실은 반드시 알아둘 필요가 있습니다. 매번 메모리를 해제하지 않고 재사용할 수 있도록 메모리 풀을 구현할 때 유용하기 때문입니다.

 

9.1.2 delete-expression과 operator delete

직접 호출할 수 있는 delete-expression은 두 개(delete, delete[])뿐 입니다. nothrow나 배치 버전은 없습니다. 하지만 operator delete는 여섯 가지나 있습니다. delete-expression과 operator delete의 짝이 맞지 않는 이유는 nothrow 버전 두 개와 배치 버전 두 개는 생성자에서 예외가 발생할 때만 사용되기 때문입니다. 이렇게 예외가 발생하면 생성자를 호출하기 전에 메모리를 할당하는데 사용했던 operator new에 대응되는 operator delete가 호출됩니다. 그런데 기존 방식대로 포인터를 delete로 삭제하면 (nothrow나 배치 버전이 아닌) operator delete나 operator delete[]가 호출됩니다. 실제로는 이로 인해 문제가 발생하지는 않습니다. C++ 표준에서 delete에서 예외를 던질 때의 동작이 명확히 정의되어 있지 않아서 실행 결과를 예측할 수 없습니다. 다시 말해 delete에서 절대로 예외를 던지면 안 되기 때문에 nothrow 버전의 operator delete를 따로 둘 필요가 없습니다. 배치 버전의 delete도 아무런 작업을 하지 않아야 합니다. 배치 버전의 new로는 메모리가 할당되지 않기 때문에 해제할 대상이 없습니다. 

 

여섯 가지 버전의 operator delete의 프로토타입은 다음과 같습니다.

void operator delete(void* ptr) noexcept;
void operator delete[](void* ptr) noexcept;
void operator delete(void* ptr, const std::nothrow_t&) noexcept;
void operator delete[](void* ptr, const std::nothrow_t&) noexcept;
void operator delete(void* ptr, void*) noexcept;
void operator delete[](void* ptr, void*) noexcept;

 

9.2 operator new와 operator delete 오버로딩

전역 함수 버전인 operator new와 operator delete는 필요에 따라 오버로딩할 수 있습니다. 이 함수는 프로그램에 new-expression이나 delete-expression이 나올 때마다 호출됩니다. 단, 클래스마다 이보다 구체적인 루틴이 정의되어 있다면 호출되지 않습니다. 다만, C++ 창시자인 비야네 스트롭스트룹은 '간이 크지 않다면 전역 operator new와 operator delete를 바꾸지 말라고'고 합니다.

 

방금 언급한 방법보다는 operator new와 operator delete를 프로그램 전체가 아닌 특정한 클래스에 대해서만 오버로딩하는 것이 좋습니다. 이렇게 오버로딩하면 해당 클래스의 객체를 할당하거나 해제할 때만 그 연산자를 호출되게 할 수 있습니다. 아래 코드를 통해 배치 버전이 아닌 네 가지 operator new와 operator delete를 클래스에 대해 오버로딩하는 예를 살펴보도록 하겠습니다.

#include <cstddef>
#include <new>

class MemoryDemo
{
public:
    virtual ~MemoryDemo() = default;

    void* operator new(size_t size);
    void operator delete(void* ptr) noexcept;

    void* operator new[](size_t size);
    void operator delete[](void* ptr) noexcept;

    void* operator new(size_t size, const std::nothrow_t&) noexcept;
    void operator delete(void* ptr, const std::nothrow_t&) noexcept;

    void* operator new[](size_t size, const std::nothrow_t&) noexcept;
    void operator delete[](void* ptr, const std::nothrow_t&) noexcept;
};

 

위 메소드들의 구현은 다음과 같습니다. 이 구현은 단순히 파라미터만 전달하여 전역 버전의 연산자를 호출합니다.

void* MemoryDemo::operator new(size_t size)
{
    std::cout << "operator new\n";
    return ::operator new(size);
}

void MemoryDemo::operator delete(void* ptr) noexcept
{
    std::cout << "operator delete\n";
    ::operator delete(ptr);
}

void* MemoryDemo::operator new[](size_t size)
{
    std::cout << "operator new[]\n";
    return ::operator new[](size);
}

void MemoryDemo::operator delete[](void* ptr) noexcept
{
    std::cout << "operator delete[]\n";
    ::operator delete[](ptr);
}

void* MemoryDemo::operator new(size_t size, const std::nothrow_t&) noexcept
{
    std::cout << "operator new nothrow\n";
    return ::operator new(size, std::nothrow);
}

void MemoryDemo::operator delete(void* ptr, const std::nothrow_t&) noexcept
{
    std::cout << "operator delete nothrow\n";
    ::operator delete(ptr, std::nothrow);
}

void* MemoryDemo::operator new[](size_t size, const std::nothrow_t&) noexcept
{
    std::cout << "operator new nothrow\n";
    return ::operator new[](size, std::nothrow);
}

void MemoryDemo::operator delete[](void* ptr, const std::nothrow_t&) noexcept
{
    std::cout << "operator delete nothrow\n";
    ::operator delete[](ptr, std::nothrow);
}

 

이렇게 정의한 MemoryDemo 클래스에 대해 객체를 할당하고 해제하는 방법은 다음과 같습니다.

int main()
{
    MemoryDemo* mem{ new MemoryDemo{} };
    delete mem;
    mem = new MemoryDemo[10];
    delete[] mem;
    mem = new (std::nothrow) MemoryDemo{};
    delete mem;
    mem = new (std::nothrow) MemoryDemo[10];
    delete[] mem;
}

사실 여기서 구현한 operator new와 operator delete는 너무 간단해서 실전에서 사용할 가능성은 없습니다.

operator new를 오버로딩할 때는 이에 대응되는 operator delete도 오버로딩해야 합니다. 그렇지 않으면 메모리가 지정한 크기만큼 할당되긴 하지만, 메모리를 해제할 때는 C++의 기본 동작에 따라 처리하기 때문에 할당 로직과 맞지 않을 수 있습니다.

모든 버전의 operator new를 오버로딩하는 것은 지나치다고 생각하기 쉽습니다. 하지만 이렇게 하는 것이 메모리 할당 방식의 일관성을 유지하는 데 도움이 됩니다. 일부 버전에 대한 구현을 생략하고 싶다면 =delete로 명시적으로 삭제합니다.

 

9.3 operator new/delete 명시적 삭제 또는 디폴트

operator new와 operator delete도 명시적으로 삭제하여 해당 클래스 객체를 동적으로 생성할 수 없게 만들 수 있습니다.

class MyClass
{
public:
    void* operator new(size_t size) = delete;
    void* operator new[](size_t size) = delete;
};

이렇게 정의한 상태에서 다음과 같이 코드를 작성하면 컴파일 에러가 발생합니다.

MyClass* p1{ new MyClass };
MyClass* p2{ new MyClass[2] };

 

9.4 operator new/delete에 매개변수 추가

operator new를 표준 형태 그대로 오버로딩할 수 있을 뿐만 아니라 매개변수를 원하는 형태로 추가해서 오버로딩할 수도 있습니다. 이렇게 매개변수를 추가하면 자신이 정의한 메모리 할당 루틴에 다양한 플래그나 카운터를 전달할 수 있습니다. 예를 들어 일부 런타임 라이브러리는 이 기능을 디버그 모드에 활용합니다. 다시 말해 추가된 매개변수로 객체가 할당된 지점의 파일 이름과 줄 번호를 받아서 메모리 누수가 발생하면 문제가 되는 문장을 알려줍니다.

 

예를 들어 MemoryDemo 클래스에서 정수 매개변수를 추가한 버전의 operator new와 operator delete 프로토타입은 다음과 같습니다.

void* operator new(size_t size, int extra);
void operator delete(void* ptr, int extra) noexcept;

이 연산자는 다음과 같이 구현할 수 있습니다.

void* MemoryDemo::operator new(size_t size, int extra)
{
    std::cout << "operator new with extra int: " << extra << std::endl;
    return ::operator new(size);
}

void MemoryDemo::operator delete(void* ptr, int extra) noexcept
{
    std::cout << "operator delete with extra int: " << extra << std::endl;
    ::operator delete(ptr);
}

매개변수를 추가해서 operator new를 오버로딩하면 컴파일러는 이에 대응되는 new-expression을 알아서 찾아줍니다. new에 추가한 매개변수는 함수 호출 문법에 따라 전달됩니다.

이렇게 정의한 연산자는 다음과 같이 사용할 수 있습니다.

int main()
{
    MemoryDemo* memp{ new(5) MemoryDemo{} };
    delete memp;
}

이렇게 operator new에 매개변수를 추가해서 정의할 때 이에 대응되는 operator delete도 반드시 똑같이 매개변수를 추가해서 정의해야 합니다. 단, 매개변수가 추가된 버전의 operator delete를 직접 호출할 수는 없고, 매개변수를 추가한 버전의 operator new를 호출할 때 그 객체의 생성자에서 예외를 던져야 호출됩니다.

 

9.5 operator delete에 메모리 크기를 매개변수로 전달

operator delete를 오버로딩할 때 해제할 대상을 가리키는 포인터뿐만 아니라 해제할 메모리 크기도 전달하게 정의할 수 있습니다. 문법은 간단합니다. operator delete의 프로토타입에 메모리 크기에 대한 매개변수를 추가해서 선언해주면 됩니다.

클래스에 매개변수로 메모리 크기를 받는 operator delete와 이 매개변수를 받지 않는 operator delete를 동시에 선언하면 항상 매개변수가 없는 버전이 먼저 호출됩니다. 따라서 크기에 대한 매개변수가 있는 버전을 사용하려면 그 버전만 정의합니다.

 

여섯 가지 operator delete를 모두 메모리 크기에 대한 매개변수를 받는 버전으로 만들 수 있습니다. 다음 예는 첫 번째 버전의 operator delete를 삭제할 메모리의 크기를 매개변수로 받는 버전으로 정의한 클래스를 보여줍니다.

class MemoryDemo
{
public:
    // .. 나머지 코드 생략
    void* operator new(size_t size);
    void operator delete(void* ptr, size_t size) noexcept;
    // .. 나머지 코드 생략
};

이렇게 수정한 operator delete를 구현할 때 다음과 같이 크기 매개변수를 받지 않는 전역 operator delete를 호출합니다.

void MemoryDemo::operator delete(void* ptr, size_t size) noexcept
{
    std::cout << "operator delete with size: " << size << std::endl;
    ::operator delete(ptr);
}

이러한 기능은 복잡한 메모리 할당 및 해제 메커니즘을 클래스에 직접 정의할 때나 유용합니다.

 

 


10. 사용자 정의 리터럴 연산자 오버로딩

C++은 다음과 같은 코드에서 사용할 수 있는 다양한 표준 리터럴을 제공합니다.

  • 'a' : Character
  • "character array" : Zero-terminated array of characters, C-style string
  • 3.14f : float, 단정밀도 부동소수점 값
  • 0xabc : 16진수 값

그러나, C++에서는 자신만의 리터럴을 정의할 수 있습니다. 사용자 정의 리터럴(User-defined literals)는 underscore(_)로 시작합니다. 그리고 뒤따르는 첫 번째 문자는 반드시 소문자이어야 합니다. 예를 들면, _i, _s, _km, _miles 등이 있습니다.

 

사용자 정의 리터럴은 literal operator을 작성하여 구현할 수 있습니다. 리터럴 연산자는 raw 또는 cooked 모드에서 동작할 수 있습니다. raw 모드에서 정의한 리터럴 연산자는 일련의 문자를 받지만, cooked 모드에서의 리터럴 연산자는 지정된 interpreted type을 받습니다. 예를 들어, 리터럴 123을 받을 때, raw 리터럴 연산자는 이를 문자 '1', '2', '3'으로 받으며, cooked 리터럴 연산자는 integer 123으로 받습니다. 리터럴 0x23이 전달되면 raw 연산자는 '0', 'x', '2', '3'으로 받으면 cooked 연산자는 integer 35로 받습니다.

 

10.1 Cooked-Mode Literal Operator

cooked-mode 리터럴 연산자는 다음의 규칙을 따릅니다.

  • To process numeric value: unsigned long long, long double, char, wchar_t, char8_t, char16_t, or char32_t 타입 중의 하나를 받음
  • To process strings: 두 개의 파라미터를 받는데, 첫 번째는 문자 배열이고, 두 번째는 문자 배열의 길이이다.

예를 들어, 다음은 사용자 정의 리터럴 _i은 복소수 리터럴을 정의하는 cooked 리터럴 연산자입니다.

std::complex<long double> operator"" _i(long double d)
{
    return std::complex<long double>{ 0, d };
}

이 _i 리터럴 연산자는 다음과 같이 사용할 수 있습니다.

int main()
{
    std::complex<long double> c1{ 9.634_i };
    auto c2{ 1.23_i }; // c2 has as type complex<long double>
}

 

다음 코드는 std::string 리터럴을 정의하기 위한 사용자 정의 리터럴 _s를 구현하는 cooked 리터럴 연산자입니다.

std::string operator"" _s(const char* str, size_t len)
{
    return std::string(str, len);
}

이 리터럴 연산자는 다음과 같이 사용할 수 있습니다.

int main()
{
    std::string s1{ "Hello World"_s };
    auto st2{ "Hello world"_s }; // str has as type string
}

만약 _s 리터럴 연산자가 없다면, 아래의 auto 타입은 const char*로 추론됩니다.

auto st2{ "Hello world" }; // str has as type const char*

 

10.2 Raw-Mode Literal Operator

raw-mode 리터럴 연산자는 파라미터로 const char*를 하나만 받습니다. 다음 예제는 raw 리터럴 연산자로 _i를 정의합니다.

std::complex<long double> operator"" _i(const char* p)
{
    // 구현 생략,
    // C-style 문자열을 파싱하고, 이를 복소수로 변환
}

이렇게 정의된 raw-mode 리터럴 연산자는 cooked 버전과 동일하게 사용할 수 있습니다.

 

Raw-mode 리터럴 연산자는 오직 non-string 리터럴에서만 동작합니다. 예를 들어, 1.23_i는 raw-mode 리터럴 연산자로 구현될 수 있지만 "1.23"_i는 불가능합니다.

 

10.3 표준 사용자 정의 리터럴

C++은 다음의 표준 사용자 정의 리터럴을 정의합니다. 이 표준 사용자 정의 리터럴은 underscore(_)로 시작하지 않습니다.

LITERAL CREATED INSTANCES OF ... EXAMPLE REQUIRED NAMESPACE
s string auto myString{ "Hello"s }; string_literals
sv string_view auto myStringView{ "Hello"sv }; string_view_literals
h, min, s, ms, us, ns chrono::duration auto myDuration{ 42min }; chrono_literals
y, d (C++20) chrono::year and day auto thisYear{ 2020y }; chrono_literals
i, il, if complex<T> with T equal to double, long double, float auto myComplexNumber{ 1.3i }; complex_literals

 

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

[C++] Function Pointer (함수 포인터)  (0) 2022.02.22
[C++] Iterator (이터레이터, 반복자)  (0) 2022.02.21
[C++] 연산자 오버로딩 (1)  (0) 2022.02.20
[C++] I/O 스트림  (0) 2022.02.19
[C++] static 키워드 (+ extern)  (0) 2022.02.18

댓글