Pages

Tuesday, January 31, 2023

std::move = when std::optional should be launched

Recently I had a chance to take a look at Rust. When returning from a function Rust uses std::option to return either class A in success or class B in exception.

And I found out something similar in C++, namely std::optional. Most of C++ users argued that "why use  std::optional when we can fully make use of null pointers?" According to C++ Committee, it was due to minimize human errors. Then we can ask one thing: what kind of errors, then?

Let's take a look at the code below:

struct Insider {
    /* whatever great data structure */
};

struct anti_memory_leak {
    Insider *insider=nullptr;
    ~anti_memory_leak() {
        if(insider) delete insider;
    }
};

There's nothing special in this code. Since the class Insider can be used optionally, it can be allocated to heap. When anti_memory_leak is removed from memory, Insider object will be also removed in destructor so we have means for memory leak. We proved that we can do it without std::optional.

...... Did we?

Then let's investigate the code below:

void doSomethingGreat()
{
    anti_memory_leak object1;
    object1.insider=new Insider();

    std::vector<anti_memory_leak> vector1;
    vector1.push_back(std::move(object1));
    vector1.back().insider->value1=20; // CRASH!
}
 

This function crashed in the last line. Why? The reason is in the one line above. When you call  vector1.push_back(), even though you use std::move() object1 is destructed and recreated. And when destructing, the destructor of anti_memory_leak is called, and it surely remove insider from the heap. In other words, vector1.back().insider becomes a dangling pointer. It's kind of unfortunate, the result is the same if you use emplace_back() instead of push_back(). Anyway the application crash.

Now is the time std::optional should be used. If you declare an object with std::optional, the memory is initialized without allocating that optional object, and it is initialized when the optional object is explicitly created. Of course there's a small overhead, but say, it's also same for other similar(?) classes like std::shared_ptr. We have raw pointers, but to manage our precious heap more safely, we can automate some of the management so that we can solve potential incidents(including both memory leak and dangling pointer) more easily.

If we refactor the code above using std::optional, it will be like this:

struct Insider {
    int value1;
    /* whatever great data structure */
};

struct anti_memory_leak {
    std::optional<Insider> insider;
    ~anti_memory_leak() {
        if(insider) delete insider;
    }
};

void doSomethingGreat()
{
    anti_memory_leak object1;
    object1.insider=Insider();

    std::vector<anti_memory_leak> vector1;
    vector1.push_back(std::move(object1));
    vector1.back().insider.value1=20; // OK
}

If the flow of the code is simple it won't be a big problem. However, if the flow becomes anyway compilcated(e.g. multithreading), there should be chances to free memory or miss the chance when we have to, regardless of my intention. Let's think of std::optional as some kind of insurance policy; though we all agree that insurance fee is somewhat "waste of money"(lol), but we spend money to prepare for the worst? I think it's the same for std::optional.

std::move = std::optional이 출동할 때

최근 Rust를 살펴볼 기회가 있었습니다. Rust는 function이 결과를 반환할때 std::option이라는걸 사용해서 정상이면 A 클래스를, 비정상이면 B 클래스를 반환하는 형태를 취하고 있더군요.

그래서...... 혹시나 해서 찾아봤더니, C++에도 std::optional이 있는 것을 발견했습니다. 다만 대부분의 사용자들은 std::optional을 왜 쓰느냐는 입장이었습니다. 우리에겐 (밉고도 고운) nullptr이 있는데 뭐하러 저런걸 쓰느냐...... 하는 거였습니다. C++ Committee의 입장은 실수를 줄이기 위해서라고 하던데, 그렇다면 대체 무슨 실수가 나올 수 있을까요?

우선, 다음의 코드를 살펴봅시다.

struct Insider {
    /* whatever great data structure */
};

struct anti_memory_leak {
    Insider *insider=nullptr;
    ~anti_memory_leak() {
        if(insider) delete insider;
    }
};

 확실히, 이 코드는 별 특이사항이 없어 보입니다. Insider는 쓰일 때도 있고 안 쓰일때도 있어서 필요한 경우에만 heap에서 생성하도록 해 두었습니다. anti_memory_leak이 삭제될 경우 Insider 객체가 존재하면 같이 지우도록 해서 메모리 누수 방지도 해 두었습니다. 우리는 std::optional이 없어도 아무런 문제가 없음을 증명했습니다.

...... 과연 그럴까요?

다음의 코드를 봅시다.

void doSomethingGreat()
{
    anti_memory_leak object1;
    object1.insider=new Insider();

    std::vector<anti_memory_leak> vector1;
    vector1.push_back(std::move(object1));
    vector1.back().insider->value1=20; // CRASH!
}
 

이 코드는 맨 마지막줄에서 프로그램이 터지는 결과를 가져옵니다. 왜 그럴까요? 이유는 바로 윗줄에 있습니다. vector1.push_back()을 호출할 경우, std::move()를 쓴다고 하더라도 object1은 파괴 후 재생성됩니다. 이 때 anti_memory_leak의 파괴자가 호출되고, 파괴자는 insider를 heap에서 제거합니다. 뒤집어서 말하면, vector1.back().insider는 dangling pointer가 되어버리는 셈이죠. 아울러 유감스럽지만, push_back()이 아니라 emplace_back()을 사용하더라도 동작은 마찬가지입니다. 어쨌든 터지는 것은 매한가지 되겠습니다.

바로 이 때가 std::optional이 출동할 차례입니다. std::optional을 사용하여 객체를 선언하면 일단 메모리는 사용되지 않은 채로 초기화되고, 추가 메모리는 실제로 해당 객체를 선언할 때 사용을 시작합니다. 물론 객체 생성에 따른 overhead가 좀 더 있긴 합니다만, 사실 그건 유사한(?) 역할을 수행하는 std::shared_ptr 같은 클래스도 마찬가지지요. raw pointer가 있지만 해당 heap 영역을 좀 더 안전하게 사용하기 위해서 운영의 일부를 자동화하여 개발자가 자칫 놓칠 수 있는 메모리 관리상의 문제점들(메모리 누수와 dangling pointer 모두)을 쉽게 해결하고자 하는 것입니다.

위의 코드를 std::optional을 사용하여 다시 작성한다면 이렇게 작성할 수 있겠습니다.

struct Insider {
    int value1;
    /* whatever great data structure */
};

struct anti_memory_leak {
    std::optional<Insider> insider;
    ~anti_memory_leak() {
        if(insider) delete insider;
    }
};

void doSomethingGreat()
{
    anti_memory_leak object1;
    object1.insider=Insider();

    std::vector<anti_memory_leak> vector1;
    vector1.push_back(std::move(object1));
    vector1.back().insider.value1=20; // OK
}

사실 객체를 다루는 코드의 흐름이 단순하다면 크게 문제가 없겠지만, 멀티스레딩이라던가, 아니면 코드가 어떤 식으로든 복잡해지게 되면 분명히 나도 모르게 메모리를 해제하게 되거나, 내지는 해제해야 할 때 못하는 경우가 생겨나게 됩니다. std::optional은 이런 경우에 대비한 일종의 보험이라 하겠습니다. 보험료를 내는 사람들은 (뭐 좀 아깝긴 하지만) 그래도 만일의 경우에 대비해서 보험료를 내는 거잖아요? std::optional도 마찬가지라고 봅니다.