전문가를 위한 C++/ C++ 연산자 오버로딩하기

Contents

(전체가 아니라 C#과 차이가 있는 부분을 중심으로 요약 정리)

연산자 오버로딩 개요

연산자 오버로딩을 하는 이유

연산자를 오버로딩 하는 이유는 자신이 정의할 클래스를 기본타입처럼 다루기 위함과 프로그램을 좀 더 세밀하게 제어하기 위함이다.

연산자 오버로딩의 한계

다음과 같은 것은 연산자 오버로딩을 할 수 없다.

  • 연산자 기호를 새로 만들 수 없다.
  • .(객체의 멤버에 접근하는 연산자), ::(스코프 지정 연산자), sizeof.?: (조건 연산자)를 비롯한 일부 연산자는 오버로딩할 수 없다.
  • 에리티(arity)는 연산자의 인수 또는 피연산자(operand)의 개수다. 에리티를 변경할 수 있는 곳은 함수 호출, new, delete 연산자 뿐이다. 그 외에 나머지 연산자는 에리티를 변경할 수 없다. ++과 같은 단항 연산자(유너리)는 피 연산자를 하나만 받고, /와 같은 이항 연산자(바이너리)는 피연산자를 두 개 받는다.
  • 연산자의 평가(evaluation) 순서를 결정하는 우선순위(precedence)와 결합순위(associativity)는 바꿀 수 없다.
  • 기본 타입 연산자의 이미는 바꿀 수 없다. 오버로딩할 수 있는 연산자는 클래스의 메서드이거나 오버로딩하려는 전역 함수의 인수 중 최소 하나가 사용자 정의타입(예: 클래스)이어야 한다. 다시 말해 int 타입의 +가 뺄셈이 되도록 의미를 변경할 수 없다.

연산자 오버로딩 선택

연산자 오버로딩은 operatorX 란 이름의 메서드나 함수를 정의하는 방식으로 정의한다. 여기서 X 자리에 오버로딩할 연산자 기호를 적는다. operator와 X사이에 공백이 있어도 된다. 예컨대 9장에 나온 SpreadsheetCell은 operator+ 연산자를 다음과 같이 선언했다.

SpreadsheetCell operator+(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs);

메서드와 전역 함수

오버로딩할 연산자를 클래스의 메서드로 정의할지, 전역 함수(주로 클래스의 friend)로 정의할지 선택해야 한다. 그러기 위해서는 먼저 두 방식의 차이점부터 알아야 한다. 연산자가 클래스의 메서드일 때는 연산자를 사용한 표현식의 좌변은 반드시 그 클래스의 객체여야 한다. 반면 전역 변수로 정의하면 좌변에 다른 타입의 객체를 적을 수 있다.

연산자는 크게 3 종류가 있다.

  • 반드시 메서드로 정의해야 하는 연산자. 
    • 어떤 연산자는 클래스 문맥을 벗어나면 의미가 없기 때문에 반드시 어떤 클래스에 속한 연산자여야 한다. 예컨대 operator=는 소속 클래스와 밀접한 관계에 있어서 클래스와 별도로 존재할 수 없다.
  • 반드시 전역 함수로 정의해야 하는 연산자
    • 연산자의 좌변에 소속 클래스가 아닌 다른 타입의 변수도 나와야 한다면 연산자를 전역 함수로 만들어야 한다. 대표적인 예로 좌변에 연산자가 속한 클래스 객체가 아닌 iostream 객체가 나와야 하는 operator<<와 operator>>가 있다. 또한 이항 연산자인 +나 -처럼 교환 법칙을 따르는 연산자는 반드시 좌변에 연산자가 속한 클래스가 아닌 타입의 변수가 나와야 한다.
  • 메서드와 전역 함수 둘 다 가능한 연산자.
    • C++ 프로그래머 사이에서 연산자를 오버로딩할 때 메서드로 만들어야 할지 전역 함수로 만들어야 할지에 대해 의견이 분분한데, 필자가 생각하는 원칙은 특별히 전역 함수로 만들어야 할 이유가 없다면 무조건 메서드로 만드는 것이 좋다. 왜냐하면 메서드로 만들면 virtual로 선언할 수 있지만 전역함수는 그럴 수 없기 때문이다.

인수 타입에 대한 결정사항

인수 타입에 대해서는 선택할 사항이 많지 않다. 앞서 설명했듯이 인수 개수를 변경할 일이 별로 없기 때문이다. 

(설명 생략)

값으로 받을지 레퍼런스로 받을지는 결정하기 쉽다. 비기본(non-primitive) 타입 매개변수는 모두 레퍼런스로 받게 만들면 된다.

const 지정 여부도 쉽게 결정할 수 있다. 변경할 일이 없다면 무조건 const로 지정한다. 

리턴 타입에 대한 결정사항

C++은 적합한 오버로딩 연산자를 찾을 때 리턴 타입을 고려하지 않는다. 이 때문에 연산자를 오버로딩할 때 리턴 타입을 마음껏 지정할 수 있다. 하지만 이렇게 허용한다고해서 반드시 그렇게 해야 한다는 것은 아니다. 굳이 하면 할 수 있지만 바람직한 코드는 아니다. 오버로딩한 연산자도 기본 타입 연산자와 동일한 타입을 리턴해야 한다.

레퍼런스와 const에 대한 결정 원칙은 리턴타입에도 똑같이 적용된다. 일반적으로 레퍼런스로 리턴할 수 있으면 레퍼런스로 리턴하고, 그렇지 않으면 값으로 리턴하는 원칙을 적용한다.

그렇다면 레퍼런스로 리턴할 수 있다는 것은 어떻게 알 수 있을까? 이 문제는 객체를 리턴하는 연산자에서만 발생한다. bool 타입, 리턴값이 없는 변환 연산자, 아무 타입이나 리턴할 수 있는 함수 호출 연산자 등은 이런 고민을 할 일이 없다. 연산자에서 객체를 새로 생성한다면 반드시 값으로 리턴한다. 그렇지 않으면 연산자를 호출한 객체나 연산자의 인수에 대한 레퍼런스를 리턴한다.

동작에 대한 결정사항

연산자를 오버로딩할 때 동작을 마음껏 구현할 수 있지만 클라이언트의 기대에 어긋나지 않게 구현해야 한다. operator+를 오버로딩한다면 덧셈 성격의 동작을 수행해야 한다. (ex 스트링 결합 같은)

오버로딩하면 안 되는 연산자

C++에서 허용하지만 오버로딩하면 안 되는 연산자가 있다. 그중에서 특히 주소 연산자 (operator&)는 오버로딩해서 좋은 점이 없을 뿐 아니라 변수의 주소를 가져온다는 C++의 기본 동작을 상식과 다르게 변경해버려 오히려 헷갈리게 된다. 연산자 오버로딩을 상당히 많이 사용하는 표준 라이브러리도 주소 연산자만큼은 오버로딩하지 않는다.

이항 부울 연산자인 operator&&와 operator||도 오버로딩하면 안 된다. C++의 단락 평가 규칙(short-circuit evaluation rule)을 적용할 수 없기 때문이다.

마지막으로 콤마 연산자(operator,)도 오버로딩할 수 없다. 콤마 연산자를 순차 연산자(sequencing operator)라고도 부르며, 한 문장에 나온 두 표현식을 분리하며 왼쪽에서 오른쪽 순으로 평가된다.

오버로딩 할 수 있는 연산자

아래 표는 오버로딩할 연산자가 속한 클래스의 이름을 T로 표현하고 이 클래스가 아닌 다른 타입을 E로 표현한다. 여기서 제시한 프로토타입은 한 예에 불과하고 연산자마다 T와 E를 조합해서 선언할 수 있다.

연산자 종류 메서드 또는 전역 함수 오버로딩 용도 프로토타입 예

operator+
operator-
operator*
operator/
operator%

이항 산술 연산자 전역 함수 권장 클래스에 이런 연산이 필요할 때

T operator+(const T&, const T&);
T operator+ (const T&, const E&);

operator+
operator-
operator~
단항 산술 및 비트 연산자 메서드 권장 클래스에 이런 연산이 필요할 때 T operator- () const;
operator++
operator—
선행 증가, 선행 감소 메서드 권장 산술 인수(int, long …)을 받는 +=이나 -=를 오버로딩할 때 T& operator++();
operator++
operator–
후행 증가, 후행 감소 메서드 권장 산술 인수(int, long …)을 받는 +=이나 -=를 오버로딩할 때 T operator++(int);
operator= 대입 연산자 메서드 클래스에 동적으로 할당한 메모리나 리소스 또는 레퍼런스 멤버가 있을 때 T& operator=(const T&);
operator+=
operator-=
operator*=
operator/=
operator%=
축약 산술 대입 연산자 메서드 권장 불변형이 아닌 클래스에서 이항 산술 연산자를 오버로딩할 때 T& operator+=(const T&);
T& operator+=(const E&);
operator<<
operator>>
operator&
operator|
operator^
이항 비트 연산자 전역 함수 권장 이런 연산이 필요할 때 T operator<<(const T&, const T&);
T operator<<(const T&, const E&);
operator<<=
operator>>=
operator&=
operator|=
operator^=
이항 비트 대입 연산자 메서드 권장 불변형으로 디자인되지 않은 클래스에서 이항 비트 연산을 오버로딩할 때 bool operator<(const T&, const T&);
bool operator<(const T&, const E&);

operator<
operator>
operator<=
operator>=
operator==
operator!=

이항 비교 연산자 전역 함수 권장 이런 연산이 필요할 때 ostream& operator<<(ostream&, const T&);
istream& operator>>(istream&, T&);
operator>>
operator<<
 I/O 스트림 연산자(입력과 출력)  전역 함수  이런 연산이 필요할 때 bool operator!() const;
operator!  부울 부정 연산자 메서드 권장  사용할 일이 거의 없다. 이 연산자보다는 bool 또는 void* 변환을 주로 사용한다 bool operator&& (const T&, const T&);
operator&&
operator||
 이항 부울 연산자 전역 함수 권장  사용할 일이 거의 없다. 있더라도 단란 평가를 적용할 수 없다. 차라리 단락 평가가 적용되지 않는 &와 |를 오버로딩하는 것이 좋다. bool operator&&(const T&, const T&);
operator[]  배열 인덱스 연산자  메서드  배열 인덱스를 지원할 때 E& operator[] (size_t) const E& operator[] (size_t) const;
operator()  함수 호출 연산자  메서드  객체를 함수 포인터처럼 만들고 싶을 때 또는 다차원 배열을 접근할 때 또는 []에서 인덱스 하나만 지정할 수 있는 제약을 벗어나고 싶을 때 리턴 타입과 매개변수를 다양하게 지정할 수 있다.
operator type()  변환 또는 캐스팅 연산자(타입마다 연산자를 따로 정의)  메서드  작성한 클래스를 다른 타입으로 변환하는 기능을 제공할 때 operator double() const;
operator new
operator new []
 메모리 할당 루틴 메서드 권장  작성할 클래스에서 메모리 할당을 제어할 때 (사용할 일이 거의 없음) void* operator new(size_t size);
void* operator new[] (size_t size);
operator delete
operator delete[]
 메모리 해제 루틴 메서드 권장  메모리 할당 루틴을 오버로딩할 때(사용할 일이 거의 없음) void operator delete(void* ptr) noexcept;
void operator delete[] (void* ptr) noexcept;
operator*
operator->
 역참조 연산자  operator*는 메서드 권장
operator->는 반드시 메서드
 스마트 포인터를 사용할 때 유용하다. E& operator*() const;
E* operator->() const;
operator&  주소 연산자  해당 사항 없음 해당 사항 없음 해당 사항 없음 
operator->*   멤버 포인터 역참조 해당 사항 없음 해당 사항 없음 해당 사항 없음
operator,  콤마 연산자 해당 사항 없음 해당 사항 없음  해당 사항 없음

우측값 레퍼런스

9장에서 우측값 레퍼런스는 좌측값 레퍼런스에서 쓰는 & 대신 &&를 쓴다고 설명한 적 있다. 9장에서 이동 대입 연산자를 구현할 때 사용했으며, 두 번째 객체가 대입 연산 후 삭제될 임시 객체일 때 컴파일러에서 이렇게 처리한다.

앞의 표에서 일반 대입 연산자의 프로토타입은 다음과 같다.

T& operator=(const T&);

이동 대입 연산자의 프로토타입도 우측값 레퍼런스를 사용한다는 점을 제외하면 이와 같다. 이 연산자는 인수를 변경하므로 const 인수를 전달할 수 없다.

T& operator=(T&&);

앞의 표에는 우측값 레퍼런스를 적용한 프로토타입 예가 없지만 대부분의 연산자는 기존 좌측값 레퍼런스를 사용하는 버전과 우측값 레퍼런스를 사용하는 버전이 함께 있어도 상관없다. 어느 버전이 적합한지는 클래스 구현마다 다르다. 9장에서 소개한 operator=를 예로 들 수 있다.

또 다른 예로 불필요한 메모리 할당을 방지한는 operator+가 있다. 예컨대 표준 라이브러리에서 제공하는 std::string 클래스는 operator+를 다음과 같이 우측값 레퍼런스로 구현했다. (여기서는 간단히 표현했다.)

string operator+(string&& lhs, string&& rhs);

이 연산자는 두 인수가 우측값 레퍼런스로 전달됐기 때문에 둘 중 하나에 대한 메모리를 재사용할 수 있다. 참고로 인수가 우측값 레퍼런스라는 말은 연산이 끝나면 삭제되는 임시 객체라는 뜻이다. 이렇게 구현된 operator+는 두 인수의 크기와 용량에 따라 다음 두 가지 동작 중 하나를 수행한다.

return std::move(lhs.append(rhs));

// 또는
return std::move(rhs.insert(0, lhs));

실제로 std::string에서 제공하는 operator+의 오버로딩 버전들을 보면 좌측값 레퍼런스와 우측값 레퍼런스를 다양하게 조합하고 있다. 그중에서 인수로 string 두 개를 받는 operator+ 연산자를 보면 다음과 같다. (여기서는 간략히 표현했다)

string operator+(const string& lhs, const string& rhs);
string operator+(string&& lhs, const string& rhs);
string operator+(const string& lhs, string&& rhs);
string operator+(string&& lhs, string&& rhs);

관계 연산자

C++ 표준 라이브러리는 <utility>라는 헤더 파일을 제공하는데, 여기서 다양한 헬퍼 함수와 클래스를 제공한다. 또한 다음과 같은 관계 연산자(relational operator)에 대한 함수 템플릿도 std::rel_ops 네임스페이스에서 제공한다.

template<class T> bool operator!=(const T& a, const T& b);  // operator== 정의 필요
template<class T> bool operator>(const T& a, const T& b);  // operator< 정의 필요
template<class T> bool operator<=(const T& a, const T& b);  // operator< 정의 필요
template<class T> bool operator>=(const T& a, const T& b);  // operator< 정의 필요

이러한 함수 템플릿은 다른 클래스에 있는 ==, < 연산자로 !=, >, <=, >=와 같은 연산자를 정의한다. 예컨대 operator==과 operator<를 구현할 때 이 템플릿에 나온 다른 관계 연산자를 그대로 가져다 쓸 수 있다. 코드에서 #include <utility> 문과 다음과 같이 using 문만 추가하면 이 템플릿을 곧바로 쓸 수 있다.

using namespace std::rel_ops;

하지만 현재 정의한 클래스뿐만 아니라 관계 연산에 관련된 모든 클래스에 대해 이 연산자가 생성된다는 문제가 있다. 또한 이렇게 자동 생성된 관계 연산자는 std::greater<T>와 같은 유틸리티 템플릿과 함께 작동하지 않는다. 마지막으로 암묵적 변환도 적용되지 않는다.

Note) 작성할 클래스에서 std::rel_ops를 그대로 사용하지 말고, 관계 연산자를 모두 구현하는 것이 좋다.

산술 연산자 오버로딩하기

단항 뺄셈과 단항 덧셈 연산자 오버로딩하기

int i, j = 4;
int i = -j; // 단항 뺄셈 연산자
int i = +i; // 단항 덧셈 연산자
int i = +(-i); // i에 대한 단항 뺄셈 연산의 결과에 대한 단항 덧셈 연산자를 적용한 예
int i = -(-i); // i에 대한 단항 뺄셈 연산의 결과에 대한 단항 뺄셈 연산자를 적용한 예

단항 뺄셈 연산자는 피연산자의 부호를 반대로 바꾸는 반면 단항 덧셈 연산자는 피연산자를 그대로 리턴한다. 여기서 주목할 점은 단항 덧셈 또는 뺄셈 연산자를 적용한 결과에 다시 단항 덧셈이나 뺄셈 연산을 적용할 수 있다는 것이다. 단항 연산자는 객체를 변경하지 않기 때문에 const로 선언해야 한다.

단항 operator- 연산자를 SpreadsheetCell 클래스의 멤버 함수로 정의한 예를 살펴보자. 단항 덧셈은 대체로 항등 연산 (identity operation)을 수행한다. 따라서 단항 덧셈에 대해서는 오버로딩을 하지 않는다.

SpreachsheetCell SpreadsheetCell::operator-() const
{
return SpreadsheetCell(-getValue());
}

operator-는 피연산자를 변경하지 않기 때문에 음수를 갖도록 SpreadsheetCell 객체를 새로 만들어 리턴해야 한다. 다시 말해 레퍼런스로 리턴할 수 없다. 이렇게 오버로딩한 연산자의 사용법은 다음과 같다.

SpreadsheetCell c1(4);
SpreadsheetCell c3 = -c1;

증가와 감소 연산자 오버로딩하기

(내용 생략)

증가와 감소 연산자는 포인터에도 적용할 수 있다. 스마트 포인터나 반복자로 사용할 클래스를 작성할 때 operator++와 operator–를 오버로딩해서 포인터 증가와 감소 연산을 제공할 수 있다.

비트 연산자와 논리 연산자 오버로딩하기

논리 연산자를 오버로딩하는 과정은 조금 복잡하다. &&나 ||는 오버로딩하지 않는 것이 좋다. 이 연산자는 개별 타입에 적용되지 않고 부울 표현식의 결과를 취합하기만 한다. 게다가 단락 규칙도 적용할 수 없다. 오버로딩한 &&나 || 연산자의 매개변수에 바인딩하기 전에 좌변과 우변을 모두 평가해야 하기 때문이다. 따라서 혹시라도 오버로딩해야 한다면 구체적인 타입에 대해 오버로딩한다.

스트림 입출력 연산자 오버로딩하기

(내용 생략)

인덱스 연산자 오버로딩하기

(예시 코드 생략)

그런데 매번 setElementAt()이나 getElementAt() 메서드를 호출하자니 좀 번거롭다. 기존 배열 인덱스를 활용할 수 있다면 훨씬 편할 것이다. 이럴 때는 인덱스 연산자를 오버로딩하면 된다. 따라서 다음과 같이 operator[]를 구현한다.

template <typename T> T& Array<T>::operator[](size_t x)
{
if (x >= mSize)
{
// 클라이언트가 요청한 원소 뒤로 kAllocSize만큼 공간을 할당한다.
// kAllocSize는 멤버변수
resize(x + kAllocSize);
}
return mElement[x];
}

template <typename T> void Array<T>::resize(size_t newSize)
{
// 더 큰 배열을 생성한다. 값은 0으로 초기화한다.
auto newArray = std::make_unique<T[]>(newSize);

// 새로 생성할 배열은 반드시 이전(mSize)보다 커야 한다.
for (size_t i = 0; i < mSize; i++)
{
// 이전 배열의 원소를 모두 새 배열로 복사한다.
newArray[i] = mElements[i];
}

// 이전 배열을 삭제하고 새 배열을 설정한다.
delete[] mElements;
mSize = newSize;
mElements = newArray.release();
}

operator[]는 x 지점의 원소를 레퍼런스로 리턴하기 때문에 원소를 설정하거나 가져오는데 모두 활용할 수 있다. 이렇게 리턴된 레퍼런스는 원소를 대입할 때도 활용할 수 있다. operator[]가 대입문의 좌변에 있으면 mElement 배열의 x 지점의 원솟값이 실제로 변경된다.

읽기 전용 operator[] 만들기

(생략)

배열의 인덱스가 정수가 아닐 때

(생략)

함수 호출 연산자 오버로딩하기

함수 호출 연산자(operator())도 오버로딩할 수 있다. 클래스를 정의할 때 operator()를 추가하면 이 클래스의 객체를 함수 포인터처럼 사용할 수 있다. 함수 호출 연산자를 제공하는 클래스의 객체를 함수 객체(function object) 또는 갆낟히 펑터(functor, 모듈함수)라 부른다. 이 연산자는 non-static 메서드로 오버로딩해야 한다.

operator()를 오버로딩하는 예를 살펴보기 위해 다음과 같이 클래스 메서드와 똑같은 동작을 수행하는 함수 호출 연산자를 정의해 보자.

class FunctionObject
{
public:
int operator() (int param); // 함수 호출 연산자
int doSquare(int param); // 일반 메서드
};

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); // 함수 호출 연산자를 호출
xSquaredAgain = square.doSquare(x); // 일반 메서드 호출

얼핏 보면 함수 호출 연산자를 사용하는 코드가 어색해 보이는데, 클래스의 객체를 만든느 특수한 메서드를 굳이 이렇게 함수 포인터처럼 보이는 형태로 정의할 필요가 있을까 싶지만, 이렇게 일반 메서드 대신 함수 객체로 만들면 함수 객체를 함수 포인터로 표현해서 다른 함수에 콜백 함수로 전달할 수 있다.

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

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

물론 앞의 장점을 전역 변수나 static 변수로도 구현할 수 있지만 함수 객체를 활용하면 코드가 훨씬 깔끔하다. 게다가 전역 변수나 static 변수는 멀티스레드 애플리케이션에서 문제를 발생시킬 수 있다. 함수 객체의 진정한 장점은 18장에서 구체적으로 설명하겠다.

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

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

다차원 배열의 인덱스를 지정할 때도 함수 호출 연산자를 활용할 수 있다. operator()를 단순히 operator[] 처럼 작동하도록 작성하되 인덱스를 하나 이상 받도록 만들면 된다. 하지만 이 기법을 적용할 때는 인덱스를 []가 아닌 ()로 묶어야 한다. (ex myArray(3, 4) = 6;)

역참조 연산자 오버로딩하기

역참조 연산자(*, ->. ->*)도 오버로딩할 수 있다. ->* 연산자에 대한 설명은 잠시 미루고, *와 -> 연산자의 기본 의미부터 살펴보자. *는 포인터가 가리키는 값에 직접 접근하는 역참조 연산자다. 반면 ->는 * 연산자 뒤에 멤버를 지정하는 . 연산자를 붙인 것을 축약한 것이다. 예컨대 다음 두 문장은 서로 의미가 같다.

SpreadsheetCell* cell = new SpreadsheetCell;
(*cell).set(5);
cell->set(5);

클래스를 직접 정의할 때 역참조 연산자를 오버로딩하면 그 클래스의 객체를 포인터처럼 다룰 수 있다. 이 기능은 스마트 포인터를 구현할 때 사용한다. 또한 반복자를 다룰 때도 유용하다. 그래서 표준 라이브러리는 이 기능을 상당히 많이 활용한다.

Caution) 아래 예제는 역참조 연산을 오버로딩하는 방법을 보여주기 위한 것일 뿐 스마트 포인터는 직접 정의하는 것보다 표준 스마트 포인터 클래스를 활용하는 것이 바람직하다.

예제에서 활용할 스마트 포인터 클래스 템플릿의 정의는 다음과 같다. 아직은 역참조 연산자를 작성하지 않았다.

template <typename T> class Pointer
{
public:
Pointer(T* ptr);
virtual ~Pointer();

Pointer(const Pointer<T>& src) = delete;
Pointer<T>& operator=(const Pointer<T>& rhs) = delete;

private:
T* mPtr = nullptr;
}

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

template <typename T> Pointer<T>::Pointer(T* ptr) : mPtr(ptr)
{
}

template <typename T> Pointer<T>::~Pointer()
{
delete mPtr;
mPtr = nullptr;
}

이렇게 정의한 스마트 포인터 클래스 템플릿을 사용하는 방법은 다음과 같다.

Pointer<int> smartInt(new int);
*smartInt = 5; // 스마트 포인터를 역참조한다.
cout << *smartInt << endl;

Pointer<SpreadsheetCell> smartCell(new SpreadsheetCell);
smartCell->set(5);
cout << smartCell->getValue() << endl;

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

operator* 구현 방법

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

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

template <typename T> class Pointer
{
public:
T& operator*();
cosnt T& operator*() const;

// 나머지 코드 생략
}

template <typename T> T& Pointer<T>::operator*()
{
return *mPtr;
}

template <typename T> const T& Pointer<T>::operator*() const
{
return *mPtr;
}

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

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->();
cosnt T& operator->() const;

// 나머지 코드 생략
}

template <typename T> T* Pointer<T>::operator->()
{
return mPtr;
}

template <typename T> const T* Pointer<T>::operator->() const
{
return mPtr;
}

operator.*와 operator->*

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

다음 예를 통해 구체적인 방법을 알아보자. 정확한 문법은 11.3.3절의 ‘메서드와 데이터 멤버를 가리키는 포인터에 대한 타입 앨리어스’를 참조한다.

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

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

SpreadsheetCell* myCell = new SpreadsheetCell();
double (SpreadsheetCell::*methodPtr) () const = &SpreadsheetCell::getvValue;
cout << (myCell->*methodPtr)() << endl;

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

변환 연산자 구현하기

다음 두 문장을 살펴보자.

SpreadsheetCell cell(1.23);
double d1 = cell; // 컴파일 에러

SpreadsheetCell을 double으로도 표현할 수 있지만 컴파일러는 SpreadsheetCell을 double로 변환하는 방법을 모르기 때문에 에러가 발생한다. 이를 해결하기 위해 다음과 같이 수정해도 마찬가지다.

double d1 = (double)cell;  // 그래도 컴파일 에러가 발생한다.

여전히 컴파일러는 SpreadsheetCell을 double로 변환하는 방법을 모르기 때문이다. 이 문장처럼 대입하고 싶다면 컴파일러에 구체적인 방법을 알려줘야 한다. 다시 말해 SpreadsheetCell을 double로 변환하는 변환 연산자를 구현해야 한다. 여기에 사용할 변환 연산자의 프로토타입은 다음과 같이 선언한다.

operator double() const;

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

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

다른 타입에 대한 변환 연산자도 이와 똑같은 방식으로 작성한다. 예컨대 SpreadsheetCell의 std::string 변환 연산자를 다음과 같이 구현할 수 있다.

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

명시적 변환 연산자로 모호한 문제 해결하기

SpreadsheetCell 객체에 대해 double 변환 연산자를 추가하면 모호함이 발생할 수 있다. 예컨대 다음 코드를 보자.

SpreadsheetCell cell(1.23);
double d2 = cell + 3.3; // operator double()을 정의했다면 컴파일 에러가 발생한다.

에러가 발생하는 이유는 컴파일러가 cell을 operator double()에 적용해서 double 덧세믕로 처리할지, 아니면 3.3을 double 생성자에 적용해서 SpreadsheetCell로 변환한 뒤 SpreadsheetCell 덧셈으로 처리할지 결정할 수 없기 때문이다. operator double()을 구현하기 전에는 고민할 일이 없었다.

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

explicit operator double() const;

부울 표현식으로 변환하기

때로 객체를 부울 표현식에서 사용하면 좋을 때가 있다.

if (ptr != nullptr) { /* 역참조 연산을 수행한다. */ }

if (ptr) { /* 역참조 연산을 수행한다. */ }

if (!ptr) { /* 역참조 연산을 수행한다. */ }

앞서 정의한 Pointer를 이렇게 사용하면 컴파일 에러가 발생하는데, Pointer에 포인터 타입으로 변환하는 연산자를 추가하면 문제없이 쓸 수 있다. 그래서 if 문의 조건에 nullptr과 비교하는 문장을 적을 때 뿐만 아니라 그냥 객체만 적어도 자동으로 포인터 타입으로 변환된다.

이런 변환 연산자는 주로 void* 타입을 사용한다. 이 포인터 타입을 사용하면 부울 표현식에서 테스트하는 용도 외에는 다르게 활용할 수 없기 때문이다. 포인터 변환 연산자의 구현 예는 다음과 같다.

template <typename T> Pointer<T>::operator void*() const
{
return mPtr;
}

이제 다음과 같이 코드를 작성해도 문제없이 컴파일 되고 의도대로 실행된다.

if (p != nullptr) { cout << "not nullptr" << endl; }
if (p != NULL) { cout << "not NULL" << endl; }
if (p) { cout << "not nullptr" << endl; }
if (!p) { cout << "nullptr" << endl; }

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

template <typename T> Pointer<T>::operator bool() const
{
return mPtr != nullptr;
}

다음과 같은 비교 연산도 문제없이 실행된다.

if (p != NULL) { cout << "not NULL" << endl; }
if (p) { cout << "not nullptr" << endl; }
if (!p) { cout << "nullptr" << endl; }

그런데 operator bool()을 이용할 때 다음과 같이 nullptr과 비교하는 문장에서 에러가 발생한다.

if (p != nullptr) { cout << "not nullptr" << endl; }

nullptr의 타입은 nullptr_t이고 자도응로 0(false)로 변환되지 않기 때문이다. operator !=과 같은 연산자를 Pointer 클래스의 friend로 구현해도 된다.

template <typename T>
class Pointer
{
public:
// 코드 생략
template <typename T>
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.mPtr != rhs;
}

그런데 operator!=을 이렇게 구현하면 다음과 같은 비교는 할 수 없게 된다. 어느 operator!=를 사용할지 컴파일러가 결정할 수 없기 때문이다.

if (p != NULL) { cout << "not NULL" << endl; }

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

Pointer<SpreadsheetCell> anotherSmartCell(new SpreadsheetCell(5.0));
int i = anotherSmartCell; // Pointer를 bool로, 다시 int로 변환한다.

그런데 이는 의도한 동작이 아니다. 대입문이 이렇게 처리되지 않게 하려면 int, long, long long 등에 대한 변환 연산자를 명싲거으로 delete해야 한다. 하지만 코드가 지저분해진다. 그래서 대부분 operator bool() 대신 operator void*()를 선호한다.

메모리 할당과 해제 연산자 오버로딩하기

C++은 메모리 할당과 해제 작업을 원하는 형태로 정의하는 기능을 제공한다. 이러한 커스터마이즈 작업은 프로그램 전반에 적용하게 만들 수도 있고, 클래스 단위로 적용하게 만들 수도 있다. 이 기능은 조그만 개겣들을 여러 차례 할당하고 해제하는 과정에서 발생하기 쉬운 메모리 파편화(memory fragmentation, 메모리 단편화)를 방지하는데 주로 사용된다. 

예컨대 메모리가 필요할 때마다 디폴트 C++ 메모리 할당 기능 대신 고정된 크기의 메모리 영역을 미리 할당해서 메모리 풀 할당자(memory pool allocator)로 만들고 여기서 메모리를 재사용하도록 구현할 수 있다. 

여기서는 이렇게 메모리 할당과 해제 루틴을 직접 만드는 방법과 이 과정에서 발생하는 여러 가지 이슈를 소개하겠다. 이 기법을 익혀두면 나중에 메모리 할당자를 직접 만들 때 써먹을 수 있다.

Caution) 다양한 메모리 할당 전략을 잘 모른다면 힘들여 메모리 할당 루틴을 오버로딩해도 큰 효과를 보기 힘들다. 단지 좋아 보인다는 이유만으로 메모리 할당 및 해제 루틴을 오버로딩하지 말기 바란다.

new와 delete의 구체적인 작동 방식

C++에서 난해한 부분 중 하나는 new와 delete의 세부 작동과정이다. 예컨대 다음 코드를 살펴보자.

SpreadsheetCell* cell = new SpreadsheetCell();

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

delete 작동 방식도 비슷하다. 다음 코드를 보자.

delete cell;

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

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

new-표현식과 operator new

new-표션식은 여섯 가지 종류가 있다. 각 버전마다 적용되는 operator new가 따로 있다. 그중 네 가지(new, new[], new(nothorw), new(nothrow)[])는 앞서 본 ㅈ거이 있다. 이러한 네 가지 형태에 대응되는 opeartor 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-표현식이다. 이를 배치 new 연산자(placement new operator)라 부르며 일반 변수 버전과 배열 버전이 있다. 이 연산자를 이용하면 다음과 같이 기존에 확배된 메모리에서 객체를 생성할 수 있다.

void* ptr = allocateMemorySomehow();
SpreadsheetCell* cell = new (ptr) SpreadsheetCell();

구문이 좀 어색하지만 이런 기능이 있다는 사실은 반드시 알아둘 필요가 있다. 매번 메모리를 해제하지 않고 재사용할 수 있도록 메모리 풀을 구현할 때 유용하기 때문이다. 두 가지 버전의 배치 new 연산자에 대응되는 operator new는 다음과 같다. 참고로 C++ 표준에서는 다음 두 가지 operator new에 대한 오버로딩을 금지하고 있다.

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

delete-표현식과 operator delete

직접 호출할 수 있는 delete-표현식은 단 두 개(delete, delete[]) 뿐이다. nothrow나 배치 버전은 없다. 하지만 operator delete는 여섯 가지나 있다. delete-표현식과 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* p, void*) noexcept;
void operator delete[](void* p, void*) noexcept;

operator new와 operator delete 오버로딩하기

전역 함수 버전인 operator new와 operator delete는 필요에 따라 오버로딩할 수 있다. 이 함수는 프로그램에 new-표현식이나 delete-표현식이 나올 때마다 호출된다. 단 클래스마다 이보다 구체적인 루틴이 정의돼 있다면 호출되지 않는다.

그런데 C++를 만든 비야네 스트롭스트룹은 <The C++ Programming Language> 3판에서 ‘간이 크지 않다면 전역 operator new와 operator delete를 교체하지 말라고 했다. 필자도 같은 생각이다.

Caution) 이렇게 경고했음에도 전역 operator new를 교체했다면 최소한 그 연산자 코드에 new를 호출하는 문장을 절대 넣지 말기 바란다. 여기에 new를 호출하는 문장을 넣어버리면 무한 재귀 호출이 발생해서 cout으로 메시지를 콘솔에 출력하는 것조차 못하게 된다.

앞서 섦옇나 기법보다는 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;
};

전역 버전의 연산자에 단순히 인수만 전달해서 호출하는 방식으로 구현한 예는 다음과 같다. 여기서 nothrow는 실제로 nothrow_t 타입의 변수다.

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

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

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

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

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

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

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

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

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

MemoryDemo* mem = new MemoryDemo();
delete mem;

mem = new MemoryDemo[10];
delete [] mem;

mem = new (nothrow) MemoryDemo();
delete mem;

mem = new (nothrow) MemoryDemo[10];
delete [] mem;

여기 나온 operator new와 operator delete의 구현 방식은 너무 간단해서 실전에 쓸 가능성은 없다. 실전에서 구현할 일이 있을 때 어떻게 작성하는지 보여주기 위한 용도일 뿐이다.

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

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

Caution) operator new의 모든 버전을 오버로딩하거나 일부만 오버로딩할 때는 사용하지 않을 버전에 명시적으로 delete를 지정해서 삭제한다. 그래야 메모리 할당 방식의 일관성을 유지할 수 있다.

operator new와 operator delete를 명시적으로 삭제하거나 디폴트로 만들기

8장에서 생성자와 대입 연산자에 delete나 default를 지정해서 명싲거으로 삭제하거나 디폴트로 설정하는 방법을 소개했다. 이렇게 명싲거으로 삭제하거나 디폴트로 설정하는 기능은 생성자와 대입 연산자만 적용할 수 있는 것은 아니다. 예컨대 다음 코드처럼 operator new와 operator new[]도 명시적으로 삭제해서 new나 new[]로 이 클래스 객체를 동적으로 생성할 수 없게 만들 수 있다.

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

operator new와 operator delete에 매개변수를 추가하도록 오버로딩하기

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

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

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

이 연산자를 구현한 예는 다음과 같다.

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

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

매개변수를 추가해서 operator new를 오버로딩하면 컴파일러는 이에 대응되는 new-표현식을 알아서 찾아준다. new에 추가한 매개변수는 함수 호출 문법에 따라 전달된다. (nothrow 버전도 마찬가지다) 이렇게 작성한 연산자를 활용하는 방법은 다음과 같다.

MemoryDemo* memp = new (5) MemoryDemo();
delete memp;

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

operator delete에 메모리 크기를 매개변수로 전달하도록 오버로딩하기

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

Caution) 클래스에 매개변수로 메모리 크기를 받는 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를 호출한다. 전역 operator delete 중에는 크기를 받는 버전이 없기 때문이다.

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

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

[ssba]

The author

지성을 추구하는 사람/ suyeongpark@abyne.com

댓글 남기기

이 사이트는 스팸을 줄이는 아키스밋을 사용합니다. 댓글이 어떻게 처리되는지 알아보십시오.