본문 바로가기
Computer Science

[C++] 표준 라이브러리 클래스 상속받기 (feat. Maybe 모나드 구현)

by invrtd.h 2022. 9. 27.

 나도 몰랐던 사실인데, 표준 라이브러리 클래스를 상속받을 수 있다. 생각해 보면 당연하긴 하다. 표준 라이브러리 클래스도 결국엔 클래스일 테니까...

 

 하지만 그렇게 하는 데는 애로사항이 조금 있을 수 있다. 내부 변수들이 private으로 선언되어 있을 수 있기 때문이다. 이 변수들은 상속받아도 접근할 수 없다. (사실 private이 아니어도 접근하기 어려울 것이다. 어차피 이름을 모르거든...) 하지만 방법은 있다. 이 글에서 C++17 표준 라이브러리 중 하나인 std::optional<T> 클래스를 상속받이 maybe<T> 모나드로 만들어 보자.

 

 일단 std::optional<T>는 값이 있을 수도 없고 없을 수도 있는 객체를 나타내는 클래스이다. 예를 들어 std::string을 int로 바꿔주는 to_integer() 함수를 만든다고 가정해 보자. 여기에 "what" 같이 정수가 아닌 값이 들어오면, 예외를 던지는 것이 가장 일반적이다. 그러나 예외를 던지는 비용이 좀 비싸다고 알려져 있으므로, 성능 최적화를 원한다면, 에러가 났다는 뜻으로 빈 값을 리턴할 수 있다. 이럴 때 std::optional<T>를 쓴다.

 그러면 우리가 만들 maybe 모나드는 std::optional의 기능적 확장 버전일 것이다. maybe 모나드가 뭘 하는 놈인지를 이 코드를 통해 살펴보자. 

 

template<int N>
auto add = [](int n) -> int {return n + N;};

template<int N>
auto multiply = [](int n) -> int {return n * N;};

template<int N>
auto add_r = [](int &n) -> void {n += N;};

template<int N>
auto multiply_r = [](int &n) -> void {n *= N;};

int main() {
    auto may = Maybe<int>(1); // may에 int 값 1을 저장
    auto may_copy = may >> multiply<3> >> add<6>;
    // may를 두 함수에 차례대로 값 전달한 뒤, 계산 결과를 may_copy에 복사
    
    std::cout << may.value() << ' ' << may_copy.value() << '\n'; // 1 9
    
    may << multiply_r<41771> << add_r<7110>;
    // may에 두 함수를 적용
    
    std::cout << may.value() << '\n'; // 48881
    
    auto may_not = Maybe<int>(); // may_not에 아무것도 저장하지 않음
    may_not << add_r<41771>;
    // may_not에 함수를 적용
    
    std::cout << may_not.has_value() << '\n'; // false
}

 

 일단 >>, << 연산자가 오버로딩되었다는 사실을 알 수 있다. 연산자를 사용한 부분만 따로 살펴보면,

 

 

may >> multiply<3>

 

 이 문장은 1이 저장된 변수 may를 주어진 함수(n을 받아 3n을 리턴하는 함수)에 대입한다는 뜻이다. 함수에 대입할 때는 보통 operator() 연산자를 쓰지만, 우리는 그렇게 하지 않았는데, operator()은 int를 받지 Maybe<int>를 받는 친구가 아니기 때문에 쓸 수가 없기 때문이다. 하지만 우리는 operator>>을 사용해서 기적적으로 int만 받는 함수에 Maybe<int>를 대입하는 데 성공했다!

 

 operator<<도 비슷한 역할을 한다. operator>>은 값 전달을 받아 값 리턴을 하고 operator<<는 참조자 전달을 받아 참조자 리턴을 한다는 사실만 다를 뿐이다. 

 

auto may_not = fff::maybe_factory.make<int>(); // may_not에 아무것도 저장하지 않음
may_not << add_r<41771>;
// may_not에 함수를 적용
    
std::cout << may_not.has_value() << '\n'; // false

 

 재미있게도 이 코드에서 볼 수 있듯이, 아무것도 저장하지 않은 객체 may_not도 함수에 집어넣을 수 있다. 물론 아무것도 아닌 값을 리턴할 것이다... 어쨌든 이 코드가 보여주는 사실은 다음과 같다.

 

 "operator>>, operator<<는 int만 받을 수 있는 함수를 Maybe<int>도 받을 수 있는 함수로 마개조시켜준다."

 

 그렇다면 바로 구현을 살펴보자.

 

template<typename T>
class Maybe : public std::optional<T> {
public:
    using std::optional<T>::optional;
    
    template<class F>
    requires std::invocable<F, T>
    constexpr Maybe<T> operator>>(F &&f) const
    noexcept(noexcept(f(this->value())))
    {
        if (this->has_value()) {
            return f(this->value());
        } else {
            return std::nullopt;
        }
    }
    
    template<class F>
    requires std::invocable<F, T &>
    constexpr Maybe<T> &operator<<(F &&f)
    noexcept(noexcept(f(this->value())))
    {
        if (this->has_value()) {
            f(this->value());
        }
        return *this;
    }
};

 

 일단 생성자를 살펴보자.

 

using std::optional<T>::optional;

 

 이렇게 하면 std::optional의 모든 생성자를 그대로 갖다 쓸 수 있다. 일일이 4개의 생성자를 다 만들어줄 필요가 없다.

 

 이것이 가능한 이유는 Maybe<T>는 std::optional의 기능적 확장일 뿐 추가적인 변수를 담고 있지 않기 때문이다. 중요한 사실이 있는데, C++에서는 표준 라이브러리의 상속은 이렇게 추가적인 변수가 없을 때만 가능하다. 추가적인 변수를 갖고 상속하는 것은 컴파일 오류를 일으키지는 않지만 나중에 메모리 누수를 일으킬 가능성이 생긴다. 그 이유는 표준 라이브러리의 소멸자가 버추얼인지 아닌지 우리는 모르기 때문이다. Base Class의 소멸자가 virtual로 선언되어 있지 않으면, Derived 객체를 소멸시킬 때 Base 소멸자가 호출될 수도 있다. 그러면 Derived 클래스가 갖고 있는 추가적인 변수는 소멸이 안 되어 메모리 누수가 발생한다.

 

 그리고 앞서 언급했지만, 우리는 std::optional의 내부 데이터를 담고 있는 변수의 이름이 뭔지 모른다. 이 부분은 public 함수를 알아서 사용해 주도록 하자.

 

template<class F>
requires std::invocable<F, T>
// 이 문장은 함수 f가 타입 T를 인자로 받을 수 있어야 한다는 뜻
// C++17 이하에서는 SFINAE나 static_assert로 대체
constexpr Maybe<T> operator>>(F &&f) const
noexcept(noexcept(f(this->value())))
{
    if (this->has_value()) {
        return f(this->value());
    } else {
        return std::nullopt;
    }
}

 

 여담이지만, 이렇게 함수를 마개조시킬 수 있는 타입 컨스트럭터를 모나드라고 부른다. 기호를 써서 말하면, 어떤 함수 T -> U를 가져오든 그 함수를 M<T> -> M<U>로 마개조시킬 수 있는 방법이 있고, 또한 어떤 함수 T -> M<U>를 가져오든 그 함수를 M<T> -> M<U>로 마개조시킬 수 있는 방법도 있으면 M이 모나드가 된다.

 

 위 구현은 전자 조건만 만족시키고 있어서 불완전한 모나드다. 나중에 후자 조건까지 만족시킬 수 있도록 구현하는 모나드 글을 파야겠다.

 

 이 구현에는 부족한 점이 많다. 예를 들어 operator>>는 T -> T 함수만 받을 수 있다. 아주 약간의 TMP 지식만 있으면 T -> U 함수를 받을 수 있는 모나드를 쉽게 구현할 수 있으나, 그러면 주제에서 너무 벗어나는 것 같아서 그러지 않았다.

댓글