使用memcpy和memset重新分配数组

6

我接手了一些代码,发现一个奇怪的数组重新分配。这是一个在Array类中使用的函数(被JsonValue使用)。

void reserve( uint32_t newCapacity ) {
    if ( newCapacity > length + additionalCapacity ) {
        newCapacity = std::min( newCapacity, length + std::numeric_limits<decltype( additionalCapacity )>::max() );
        JsonValue *newPtr = new JsonValue[newCapacity];

        if ( length > 0 ) {
            memcpy( newPtr, values, length * sizeof( JsonValue ) );
            memset( values, 0, length * sizeof( JsonValue ) );
        }

        delete[] values;

        values = newPtr;
        additionalCapacity = uint16_t( newCapacity - length );
    }
}

我明白了,这只是为了分配一个新数组,并将旧数组的内存内容复制到新数组中,然后将旧数组的内容清零。我知道这是为了防止调用析构函数和移动操作而执行的。
JsonValue是一个带有函数和一些数据的类,这些数据存储在联合体中(例如字符串、数组、数字等)。
我的担忧是这是否属于已定义的行为。我知道它能够正常工作,并且在我们几个月前开始使用以来没有出现过问题;但是如果它是未定义的,那么它并不意味着它会持续工作。
编辑:JsonValue看起来像这样:
struct JsonValue {
// …
    ~JsonValue() {
        switch ( details.type ) {
        case Type::Array:
        case Type::Object:
            array.destroy();
            break;
        case Type::String:
            delete[] string.buffer;
            break;
        default: break;
        }
    }
private:
    struct Details {
        Key key = Key::Unknown;
        Type type = Type::Null; // (0)
    };

    union {
        Array array;
        String string;
        EmbedString embedString;
        Number number;
        Details details;
    };
};

Array 是围绕着一个 JsonValue 数组的包装器,String 是一个 char*EmbedString 是一个 char[14]Number 是一个由 intunsigned intdouble 构成的联合体,Details 包含了它所持有的值的类型。所有的值在开头都有 16 位未使用的数据,用于存储 Details。例如:

struct EmbedString {
    uint16_t : 16;
           char buffer[14] = { 0 };
};

values 是从哪里来的?它是实例变量还是全局变量? - SimonC
@ChrisMM 这个语句 memset( values, 0, length * sizeof( JsonValue ) ) 没有意义。 - Vlad from Moscow
@SimonC,这是一个类成员变量。抱歉,reserve函数是类的一部分。 - ChrisMM
1
在(立即)删除数组之前明确将“values”内存设置为零的可能原因是为了防止安全漏洞 - 即其他程序入侵已释放的内存,其中可能仍包含(敏感)数据。 - Adrian Mole
1
这对我来说看起来像是一个自制的 std::vector 替代品。我想知道为什么一开始没有使用 std::vector。 - n. m.
显示剩余12条评论
1个回答

4
这段代码是否具有良好的定义行为,基本上取决于两个因素:1)JsonValue是否是平凡可复制(trivially-copyable)的;2)如果是的话,一堆全零字节是否是JsonValue的有效对象表示。
如果JsonValue是平凡可复制的,那么从一个JsonValue数组到另一个的memcpy确实等同于复制所有元素:[basic.types]/3。如果所有零值都是JsonValue的有效对象表示,则memset应该是可以的(我认为这实际上落入了标准的措辞的灰色地带,但我相信至少意图是这样的)。
此外,我建议摆脱这些StringEmbedString类,直接使用std::string。至少,我认为EmbedString的唯一目的是手动执行小字符串优化。任何值得称道的std::string实现在底层都会这样做。请注意,std::string不能保证(通常不会)平凡可复制。因此,您无法仅仅通过将StringEmbedString替换为std::string来保留当前实现的其余部分。
如果您可以使用C++17,我建议简单地使用std::variant来代替或至少在这个自定义的JsonValue实现中使用,因为它似乎正是它试图做的事情。如果需要存储一些常见信息以供变量值使用,请在持有变量值的成员之前具有一个适当的成员来保存该信息,而不是依赖于联合的每个成员都以相同的一对成员开始(只有所有联合成员都是标准布局类型,并保留了它们的公共初始序列,才能良好定义:[class.mem]/23)。 Array的唯一目的似乎是作为向量来清零内存,以达到安全释放内存的目的。如果是这样的话,我建议使用带有在释放前清零内存的分配器的std::vector。例如:
template <typename T>
struct ZeroingAllocator
{
    using value_type = T;

    T* allocate(std::size_t N)
    {
        return reinterpret_cast<T*>(new unsigned char[N * sizeof(T)]);
    }

    void deallocate(T* buffer, std::size_t N) noexcept
    {
        auto ptr = reinterpret_cast<volatile unsigned char*>(buffer);
        std::fill(ptr, ptr + N, 0);
        delete[] reinterpret_cast<unsigned char*>(buffer);
    }
};

template <typename A, typename B>
bool operator ==(const ZeroingAllocator<A>&, const ZeroingAllocator<B>&) noexcept { return true; }

template <typename A, typename B>
bool operator !=(const ZeroingAllocator<A>&, const ZeroingAllocator<B>&) noexcept { return false; }

然后

using Array = std::vector<JsonValue, ZeroingAllocator<JsonValue>>;

注意:我使用volatile unsigned char*来填充内存,以防止编译器优化掉清零操作。如果需要支持超对齐类型,则可以将new[]delete[]替换为直接调用::operator new::operator delete(这样做可以防止编译器优化掉分配操作)。在C++17之前,您需要分配足够大的缓冲区,然后手动对齐指针,例如使用std::align...

@n314159 感谢您指出这一点,我确实不知道在删除旧数组之前指针被复制到新数组中。我已经从我的答案中删除了那部分。除此之外,我相信这里没有生命周期问题。memcpy不会创建对象。但是,新数组元素的生命周期已经开始于new JsonValue[newCapacity],而memcpy并不会结束它... - Michael Kenzel
我认为这是一种非常“C式”的做法。C++有很多优秀的容器类,可以轻松地容纳各种数量的任何东西。是否可能使用其中之一呢? - Mike Robinson
@MichaelKenzel 是的,我也看到了,在写一个关于生命周期@new^^的UB答案时。你认为将JsonValuemove构造函数编写成这样的方式是否是个好主意,以便它可以复制并清零移动的类型?然后就可以像正常移动一样完成所有操作。当然,这意味着总是要清零JsonValue,但如果这确实是出于安全考虑,那么似乎是合适的。 - n314159
@MichaelKenzel,安全性是一个小问题,但不是太大的问题。我认为零化只是为了防止析构函数释放不应该释放的内存,但比为每个对象设置type = Type::Null更容易。我相信之所以使用两个字符串而不是std::string是出于内存原因。sizeof(std::string)为32,而sizeof(JsonValue)为16。在此实现之前,它只是使用常规的nlohmann::json对象,我相信它确实使用了std::string,并且这样处理一些文件需要20GB以上的内存。 - ChrisMM
@MichaelKenzel 你说如果类型是平凡可复制的,就已经定义好了。既然不是,你知道反过来是否成立吗?那么它肯定是未定义的行为吗? - n314159
显示剩余19条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接