프록시(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 리터럴의 프록시이다.
'Computer Science > C++' 카테고리의 다른 글
충격!) C++에서 Rust의 블록 표현식과 유사한 구문을 사용할 수 있다?! (0) | 2024.03.04 |
---|---|
[C++] 객체지향, 제네릭 세그먼트 트리 라이브러리 구현하기 (0) | 2023.10.03 |
C++ 버전이 바뀌면서 의미가 달라진 키워드들 (0) | 2023.01.09 |
[C++] TMP로 컨테이너 내부 타입 정보 얻어오기 [템플릿 메타프로그래밍] (0) | 2022.10.22 |
C++ Dynamic Programming 꿀팁 (0) | 2022.08.23 |
댓글