用C++優雅的實現物件到檔案的序列化/反序列化

落單的毛毛蟲發表於2019-06-19

需求

.  在寫程式碼的過程中,經常會需要把程式碼層面的物件資料儲存到檔案,而這些資料會以各種格式儲存.例如:json,xml,二進位制等等.最近恰好就需要把物件以二進位制儲存到硬碟.這是一個很簡單的需求,相比json,xml格式,二進位制是直接把位元組copy到硬碟,沒有中間商賺差價,所以這實現起來相對容易.

實現

struct Vec3 {
    float x;
    float y;
    float z;
}

.  上面是一個簡單的三維向量結構體,如何把它序列化到檔案呢?

Vec3 v;
v.x = 1.0f;
v.y = 2.0f;
v.z = 3.0f;
os.write((const char *)&v, sizeof(Vec3));

.  上述是序列化Vec3物件資料到檔案的程式碼,非常直接.它的記憶體佈局是3個浮點型變數緊湊排列,要把它儲存到硬碟,只要從頭到尾按位元組拷貝即可.但是,在實際開發中,要序列化的物件不可能全部都是記憶體緊湊排列的,例如STL容器.

std::vector<Vec3> vec;

.  如果將容器變數從頭到尾拷貝到檔案,必然會出現錯誤.因為容器內部通過一個指標來訪問儲存的物件,而直接拷貝這個容器,只會把指標拷貝,指標指向的資料卻丟失了.但是,容器提供了一個可以直接訪問指標指向資料的介面,我們可以通過這個介面得到資料然後直接拷貝.

os.write((const char *)&vec, vec.size() * sizeof(Vec3));        //  錯誤, 僅拷貝指標
os.write((const char *)vec.data(), vec.size() * sizeof(Vec3));  //  正確, 資料被完全拷貝

.  通過這個方法就可以得到正確的拷貝結果了.通常,好的做法是將序列化和反序列化封裝成介面,以便於使用,如何封裝介面,就是這篇文章的主題.

.  從上述兩個例子可以發現,對於單體物件和陣列物件,編寫的程式碼是不一樣的,單體物件直接拷貝,陣列物件需要通過 .data() 取得資料地址再進行拷貝.而考慮到還有巢狀陣列物件 std::vector<std::vector<Vec3>>.對於巢狀陣列序列化的程式碼可能如下:

std::vector<std::vector<Vec3>> vec2;
for (auto & vec: vec2)
{
    os.write((const char *)vec.data(), vec.size() * sizeof(Vec3));
}

.  可以發現,對巢狀陣列物件的序列化程式碼跟上述2種物件又不一樣,考慮到還有N層巢狀的陣列物件.此外,在C++中有一個可平凡複製的概念,通俗的說,就是可以直接按位元組拷貝的結構稱之為可平凡複製,上述的Vec3則是一個可平凡複製結構,而STL容器則不是可平凡複製結構,除此之外還有更多不可平凡複製且非容器的結構,故此,如果要封裝介面,除了區分單體物件和陣列物件,還要區分可平凡複製和不可平凡複製.

void Serialize(std::ostream & os,   const Type & val);  //  序列化
void Deserialize(std::istream & is,       Type & val);  //  反序列化

.  上面是比較理想的介面原型,序列化/反序列化各一個介面,腦補一下,這兩個介面的實現應該是怎樣的?最直接的實現是對每一種型別過載一個定義,例如:

//  string
void Serialize(std::ostream & os, const std::string & val)
{
    os.write(str.data(), str.size());
}
//  vector<int>
void Serialize(std::ostream & os, const std::vector<int> & val)
{
    os.write(str.data(), str.size() * sizeof(int));
}
//  vector<string>
void Serialize(std::ostream & os, const std::vector<std::string> & val)
{
    for (auto & str: val)
    {
        Serialize(os, str);
    }
}

//  介面呼叫
std::string str;
std::vector<int> vecint;
std::vector<std::string> vecstr;
Serialize(os, str);
Serialize(os, vecint);
Serialize(os, vecstr);

.  從上面可以看出,介面統一,使用方便.但是對每一種型別都過載,要寫的程式碼實在太多了,萬一要序列化一個多層巢狀陣列,會寫的懷疑人生.藉助C++強大的語言特性,這一切都可以一步到位.

//  POD
template <class T, typename std::enable_if_t<std::is_trivially_copyable_v<T>, int> N = 0>
void Serialize(std::ostream & os, const T & val)
{
    os.write((const char *)&val, sizeof(T));
}

//  容器
template <class T, typename std::enable_if_t<
    std::is_same_v<typename T::iterator, decltype(std::declval<T>().begin())> &&
    std::is_same_v<typename T::iterator, decltype(std::declval<T>().end())>, int> N = 0>
    void Serialize(std::ostream & os, const T & val)
{
    unsigned int size = val.size();
    os.write((const char *)&size, sizeof(size));
    for (auto & v : val) { Serialize(os, v); }
}

//  POD
template <class T, typename std::enable_if_t<std::is_trivially_copyable_v<T>, int> N = 0>
void Deserialize(std::istream & is, T & val)
{
    is.read((char *)&val, sizeof(T));
}

//  容器
template <class T, typename std::enable_if_t<
    std::is_same_v<typename T::iterator, decltype(std::declval<T>().begin())> &&
    std::is_same_v<typename T::iterator, decltype(std::declval<T>().end())>, int> N = 0>
    void Deserialize(std::istream & is, T & val)
{
    unsigned int size = 0;
    is.read((char *)&size, sizeof(unsigned int));
    val.resize(size);
    for (auto & v : val) { Deserialize(is, v); }
}

.  以上實現可序列化任意可平凡拷貝結構,並且也可序列化任意巢狀層數的STL風格陣列.而對於不可平凡複製結構,只需要針對該結構過載即可.藉助C++強大的型別推導機制和SFINEA機制,可保證型別安全又具備可擴充套件性.

相關文章