스토리텔링 개발자

[More Effective C++] 30. 프록시 클래스 본문

개발/More Effective C++

[More Effective C++] 30. 프록시 클래스

김디트 2024. 10. 4. 11:35
728x90

항목 30. 프록시(Proxy) 클래스

 

 

 

C++의 n차원 배열 문제
  • 변수를 배열의 차원으로 사용할 수가 없다.
int data[10][20]; // 컴파일 성공

void processInput(int dim1, int dim2)
{
    int data[dim1][dim2]; // 컴파일 에러!
    ...
}

void processInput2(int dim1, int dim2)
{
    int* data = new int[dim1][dim2]; // 컴파일 에러!
    ...
}

 

 

 

2차원 배열 클래스 만들기
template<typename T>
class Array2D
{
public:
    Array2D(int dim1, int dim2);
    ...
};

// 이런 식으로 사용할 수 있다.
Array2D<int> data(10, 20);
Array2D<float>* data = new Array2D<float>(10, 20);

void processInput(int dim1, int dim2)
{
    Array2D<int> data(dim1, dim2);
    ...
}
  • 하지만 대괄호를 사용하는 인터페이스가 아닌 것이 아쉽다.
    • operator[][]를 재정의할 수 있을까?
    • 아쉽게도 그런 연산자는 지원하지 않는다.(항목 7 참조)
  • 아쉬운대로 괄호를 사용하여 2차원 배열에 접근하도록 만든다.
template<typename T>
class Array2D
{
public:
    T& operator()(int index1, int index2);
    const T& operator()(int index1, int index2) const;
    ...
};

cout << data(3, 6);
  • 장점
    • 구현하기가 쉽고 차원이 늘어나도 원하는 대로 일반화하기 쉽다.
  • 단점
    • 대괄호가 들어가 있지 않아서 배열 접근보다는 함수 호출처럼 보인다.
  • C++ 기본 다차원 배열을 다시 한 번 훑어보자.
int data[10][20];
...
cout << data[3][6]; // 이는 사실 2차원 배열이 아니다.
// data는 10개의 요소를 가진 1차원 배열 안에 20개의 요소 1차원 배열을 넣은 것이다.
// 즉, (data[3])[6] 이라고 할 수 있다.
  • 이 논리를 사용해서 operator[]의 오버로딩을 통해서 이를 구현할 수 있을 것이다.
template<typename T>
class Array2D
{
public:
    // 이 클래스에 대해서는 사용자가 전혀 알 필요가 없다.
    class Array1D
    {
    public:
        T& operator[](int index);
        const T& operator[](int index) const;
        ...
    };
    
    Array1D operator[](int index);
    const Array1D operator[](int index) const;
    ...
};

Array2D<float> data(10, 20);
...
cout << data[3][6]; // 성공!!
  • Array1D 객체는 Array2D를 사용하는 사용자를 수면 아래에서 보조해주는데
    • 이를 프록시 객체(proxy object)라고 하며, 이 클래스를 프록시 클래스(proxy class)라고 한다.
    • 대리자(surrogate)라고 하기도 한다.

 

 

 

operator[]의 읽기 / 쓰기를 구분하기
  • 프록시 클래스를 사용해서 operator[]의 읽기와 쓰기 동작을 구분해 보자.
  • 앞서 참조 카운팅에서 읽기 / 쓰기 버전의 operator[]를 다루었었다.(항목 29 참조)
    • 읽기 / 쓰기 가능한 버전의 경우 객체를 새로 생성하고 공유 플래그를 꺼준 후 리턴하여 처리했다.
    • class String
      {
      public:
          const char& operator[](int index) const; // 읽기 전용
          char& operator[](int index); // 쓰기 지원
          ...
      };
       
    • 하지만 이 경우, 호출한 객체가 상수이냐를 따지기 때문에 완벽한 해결책이 아니다.
    • String s1, s1;
      cout << s1[5]; // 읽기 전용을 쓰고 싶지만, s1은 상수가 아니므로 쓰기 버전이 불린다.
      s2[5] = 'x'; // 마찬가지
      s1[3] = s2[8]; // 역시 마찬가지
    • 즉, 무조건 쓰기 동작이 일어날 것임을 상정한 구현이었다.
  • 읽기 / 쓰기를 구분하는 기본 아이디어
    • operator[]의 결과값이 사용되기 전까지 좌항값 / 우항값 연산을 늦추자.( 지연 평가(항목 17 참조) )
    • 그리고 이 지연평가를 프록시 클래스가 맡도록 한다.
class String
{
public:
    class CharProxy
    {
    public:
        // 프록시 생성
        CharProxy(String& str, int index);
        
        // lvalue 연산용
        CharProxy& operator=(const CharProxy& rhs);
        charProxy& operator=(char c);
        
        // rvalue 연산용
        operator char() const;  // char로 암시적 변환
    private:
        String& thsString;
        int charIndex;
    };
    
    const CharProxy operator[](int index) const;
    CharProxy operator[](int index);
    ...

friend class CharProxy;

private:
    RCPtr<StringValue> value;
};

// operator[]는 문자열 자체를 전혀 수정하지 않고 프록시 객체만 리턴한다.
const String::CharProxy String::operator[](int index) const
{
    // 상수 프록시 객체를 리턴하지만,
    // CharProxy::operator=는 const 멤버 함수가 아니므로
    // 상수 프록시에 대해서는 대입 연산을 사용할 수 없다.(보너스 효과!)
    
    // 또한, const_cast를 사용하여 상수성을 해제하고 있지만,
    // 리턴값이 상수 프록시이므로 수정될 염려가 없다.
    
    return CharProxy(const_cast<String&>(*this), index);
}
String::CharProxy String::operator[](int index)
{
    return CharProxy(*this, index);
}

String::CharProxy::operator char() const
{
    return theString.value->data[charIndex];
}

String::CharProxy& String::CharProxy::operator=(const CharProxy& rhs)
{
    // 공유 중이라면, 자신만 수정되도록 사본을 만든다.
    if(thisString.value->isShared())
        thisString.value = new StringValue(theString.value->data);
    
    // 대입하여 string을 변경해준다.
    theString.value->data[charIndex] = rhs.theString.value->data[rhs.charIndex];
    
    return *this;
}

String::CharProxy& String::CharProxy::operator=(char c)
{
    // 공유 중이라면, 자신만 수정되도록 사본을 만든다.
    if(thisString.value->isShared())
        thisString.value = new StringValue(theString.value->data);
    
    // 대입하여 string을 변경해준다.
    theString.value->data[charIndex] = c;
    
    return *this;
}



String s1, s2;

cout << s1[5]; // CharProxy의 operator char()로 암시적 변환되어 성공
s2[5] = 'x'; // operator=(char)이 사용되어 내부값 변경
s1[3] = s2[8]; // operator=(CharProxy&)이 사용되어 내부값 변경

 

 

 

프록시 클래스의 단점
  • 객체가 lvalue로 사용되는 경우는 대입 연산자 외에도 있기 때문에, 프록시 객체가 능사는 아니다.
  • 문제 1. 주소 연산자를 사용하는 경우
String s1 = "Hello";
char* p = &s1[1]; // s1[1]은 CharProxy를 리턴하므로 컴파일 에러!
// char*에 CharProxy*를 대입하려 하나, 이런 변환은 정의되어 있지 않다.
  • 주소 연산자(address of, &연산자)를 재정의하여 해결하면 되지 않나?
class String
{
public:
    class CharProxy
    {
    public:
        ...
        char* operator&();
        const char* operator&() const;
        ...
    };
    ...
};

const char* String::CharProxy::operator&() const
{
    return &(thsString.value->data[charIndex]);
}

// const가 아닌 버전은 수정될 수도 있는 문자열의 포인터를 반환하므로 주의.
// const가 아닌 String::operator[]와 꽤 흡사하다.(항목 29 참조)
char* String::CharProxy::operator&()
{
    // 공유 중이면 수정될 수 있기 때문에 사본 생성
    if(thsString.value->isShared())
        theString.value = new StringValue(theString.value->data);
    
    // 반환한 포인터를 언제까지 사용할지 전혀 예측할 수 없으므로 공유 가능 해제
    theString.value->markUnshareable();
    
    return &(thsString.value->data[charIndex]);
}
  • 문제 2. operator+=, operator++, operator*=, operator<<=, operator-- 등을 사용하는 경우
template<typename T>
class Array
{
public:
    class Proxy
    {
    public:
        Proxy(Array<T>& array, int index);
        Proxy& operator=(const T& rhs);
        operator T() const;
        ...
    };
    
    const Proxy operator[](int index) cosnt;
    Proxy operator[](int index);
    ...
};

Array<int> intArray;

intArray[5] = 22; // 컴파일 성공
intArray[5] += 5; // 컴파일 에러!!
++intArray[5]; // 컴파일 에러!!
  • raw 배열처럼 동작시키려면 프록시 클래스에 이를 모두 구현해 줘야 한다.. 매우 할 일이 많다.
  • 문제 3. 프록시를 통해 객체의 멤버 함수를 호출할 때
class Rational
{
public:
    Rational(int numerator = 0, int denominator = 1);
    int numerator() const;
    int denominator() const;
    ...
};

Array<Rational> array;

cout << array[4].numerator(); // 컴파일 에러!!
int denom = array[22].denomitator(); // 컴파일 에러!!
  • 원래 객체의 기능에 대한 인터페이스를 프록시에 모두 구현해야 한다..? 끔찍한 일이다.
  • 문제 4. 비상수 객체 참조자를 받아들이는 함수의 매개변수로 프록시를 넘기는 경우
void swap(char& a, char& b);
String s = "+C+";
swap(s[0], s[1]); // 컴파일 에러!!

// char로의 암시적 변환만 제공하며, char&로의 암시적 변환은 불가능하다.

// 또한, char는 swap 함수의 char&에 바인딩 될 수 없다.
// char는 임시객체이며, 컴파일러는 임시 객체를 비상수 참조차 매개변수에 바인딩하지 않는다.
// (항목 19 참조)
  • 문제 5. 암시적 타입 변환 문제. 2번 이상의 암시적 타입 변환은 컴파일러가 지원하지 않는다.
class TVStation
{
public:
    TVStation(in channel);
    ...
};

void watchTV(const TVStation& station, float hoursToWatch);

watchTV(10, 2.5); // 컴파일 성공

Array<int> intArray;
intArray[4] = 10;
watchTV(intArray[4], 2.5); // 컴파일 에러!!
// Proxy<int> -> int -> TVStation 으로, 암시적 변환이 2회 이상이다.

 

 

 

정리
  • 프록시 클래스는 어떤 객체를 대신하여 동작하게 하는 장치이다.
    • 다차원 배열, lvalue / rvalue 구분, 암시적 타입 변환 방지 를 프록시 클래스로 해결했다.
  • 함수로 반환되는 프록시 객체는 임시 객체(항목 19 참조)이므로 객체 생성, 소멸이 필수적이다.
    • 이 비용은 공짜가 아니다.
  • 원래의 객체를 사용하는 클래스를 프록시 버전으로 바꾸면, 그 클래스의 의미구조를 어쩔 수 없이 바꿔야 하는 경우가 더러 생긴다.
    • 프록시 객체는 대개 원래 객체와 약간 다른 동작패턴을 보이기 때문이다.
    • 하지만 이가 드러나는 건 매우 드물고, 보통은 사용자는 별 위화감 없이 프록시 객체를 사용할 수 있다.
728x90
Comments