본문 바로가기
Computer Science/C++

[C++] 프록시(Proxy) 디자인 패턴 (feat. bitset)

by invrtd.h 2022. 9. 18.

프록시(Proxy)는 대리자라는 뜻을 갖고 있다. 즉 어떤 객체가 하는 일을 그대로 흉내내는 객체이다. C++에서 프록시의 가장 유명한 예시는 바로 스마트 포인터(Smart Pointer)일 것이다. 스마트 포인터는 자신이 소멸될 때 delete 연산자를 알아서 호출해서 메모리 누수를 막는 똑똑한 객체인데, 당연하지만 스마트 포인터 그 자체는 포인터가 아니라 객체이다. 그럼에도 스마트 포인터는 일단 포인터가 할 수 있는 모든 일을 할 수 있도록 설계되어 있다(unique_ptr 같은 경우 복사 연산과 복사 대입 연산이 안 되기는 하지만). 특히 * 연산자와 -> 연산자를 제공하므로 사용자는 스마트 포인터를 그냥 포인터 쓰듯이 쾌적하게 사용할 수 있을 것이다.

 

이 글에서는 비트셋(bitset)에서 프록시 디자인 패턴이 어떻게 쓰였는지 알아보자. 비트셋이란 그냥 비트를 모아 놓은 것이다. 그런데 왜 여기서 프록시가 필요할까? 왜냐하면 C++에서 bool 자료형의 크기는 1비트가 아니라 1바이트(8비트)기 때문이다. 따라서 비트셋을 다음과 같이 설계하는 것은 8배의 공간 낭비가 생긴다.

 

std::array<bool, 120> ar{};
std::cout << sizeof(ar) << '\n'; // 120

 

120개의 비트를 저장하는 데는 120bit = 15byte면 충분한데, 왜 120byte씩이나 낭비해야 하는가? 그래서 우리는 std::array<bool, N>을 대체할 새로운 array-like class를 만들기로 했다. 이 클래스는 array-like이므로 다음과 같은 기능을 충실히 수행할 수 있어야 한다.

 

int main() {
    Bitset<20> bs;
    bs[3] = bs[7] = bs[13] = true;
    
    for (int i = 0; i < 20; ++i) {
        std::cout << bs[i];
    } std::cout << '\n';
}

 

std::cout << bs[i]를 구현하는 것은 쉽다. C 비트 연산에 대한 약간의 지식이 필요하긴 하지만, 사전 지식이 있는 사람은 누구나 할 수 있을 것이다. 문제가 되는 부분은 bs[3] = bs[7] = bs[13] = true // 이 부분. 뭐가 문제인지 알아보기 위해 array 클래스의 [] 연산자가 대충 어떤 식으로 구현되어 있는지 살펴보자. 

 

class Array {
    int *p;
    
    /*
     * 생성자 구현부 생략
     */
    
    int &operator[](std::size_t idx) {
        return p[idx];
    }
};

 

코드에서 볼 수 있듯이 operator[]은 참조자를 돌려준다. 여기서 참조자를 돌려주는 게 당연한 것이, 그래야 arr[10] = 13; 같은 구문이 제대로 작동하는 구문이 된다. 만약 operator[]이 값을 돌려주었다면, arr[10]은 class Array 내부의 원소가 아니라 그저 그 원소의 복사본일 뿐이니까. 그리고 operator[]이 값을 돌려준다면 rvalue에 대입을 하는 꼴이 돼서 컴파일조차 되지 않는다...

 

이 아이디어를 비트셋 구현에 그대로 사용할 수 있을 것 같다. 그러나 곧 문제에 부딪힌다. C++은 비트에 대한 참조자를 지원하지 않는다는 사실이다. 여기서 프록시(Proxy) 디자인 패턴이 빛을 발한다. 이제 마치 비트에 대한 참조자처럼 일하지만 사실은 참조자가 아닌, 그런 클래스를 만들어 보도록 하자.

 

template<std::size_t SIZE>
class Bitset {
public:
    class RefProxy {
        uint8_t &data;
        char points;
    public:
        RefProxy(uint8_t &ui, char points) : data(ui), points(points) {}
        
        operator bool() {
            return static_cast<bool>((data >> points) & 1U);
        }
        bool operator=(bool b) {
            if (b) {
                data |= (1U << points);
            } else {
                data &= ~(1U << points);
            }
            
            return b;
        }
    };
    
private:
    std::array<uint8_t, (SIZE - 1) / 8 + 1> _data{};
    
public:
    Bitset() = default;
    
    auto operator[](std::size_t idx) {
        return RefProxy(_data[idx / 8], idx % 8);
    }
};

 

구현 방식은 간단하다. 클래스 Bitset<SIZE>::RefProxy는 비트에 대한 참조자처럼 행동하지만, 사실은 그 비트를 포함하고 있는 바이트를 참조하고, 그리고 그 바이트 내에서 몇 번째 비트에 접근할 것인지를 나타내는 원소 points를 갖고 있다. implicit으로 선언된 operator bool()을 통해 이 클래스는 마치 bool 객체처럼 활용 가능하다. 또 operator=을 통해 해당 비트를 실제로 수정할 수 있다.

 

여담이지만, 실제로 C++ STL의 bitset도 그렇게 구현되어 있을까? 정답은 '그렇다'이다. 다음 코드로 직접 확인해 보자.

 

#include <bits/stdc++.h>

int main() {
    bool b = true;
    std::bitset<41771> bits;
    
    std::cout << typeid(b).name() << '\n';				
    // b
    std::cout << typeid(bits[7110]).name() << '\n';
    // NSt6bitsetILm41771EE9referenceE
}

 

bits[7110]의 typeid가 bool이 아니라 bitset<41771>::reference라는 사실을 알 수 있다. 따라서 bits[7110]은 bool 리터럴이 아니라 bool 리터럴의 프록시이다.

댓글