右值引用那些事兒

本文原名《Rvalue Refernces, Move Semantics, and Perfect Forwarding》,發表于公司博客,現分享給大家。

前言

本文將要介紹的是C++11新特性中的精華——右值引用,Move語義,完美轉發。也許你不曾聽說過這些概念,但不用擔心,閱讀本文將會使你對它們有一個大致的了解。當然,不經歷千百行代碼的歷練,你永遠無法理解C++的真諦。本文旨在拋磚引玉,請務必帶著批判的心情閱讀。

預備知識

先來復習一下C++中的幾種特殊函數,它們分別是Copy constructor、Copy assignment operator、Move constructor(C++11)、Move assignment operator(C++11)。其中,后兩者是C++11中新增的。這幾個函數的原型和調用途徑如下:

public:
    A(const A& a)
    {
        // Copy constructor
    }
    A& operator=(const A& a)
    {
        // Copy assignment operator
    }
    A(A&& a)
    {
        // Move constructor
    }
    A& operator=(A&& a)
    {
        // Move assignment operator
    }
};

A a;                    // Constructor
A a1 = a;               // Copy constructor
A a2(a);                // Copy constructor
a2 = a;                 // Copy assignment operator
A a3 = std::move(a);    // Move constructor
A a4(std::move(a));     // Move constructor
A a5(A());              // Move constructor
a5 = std::move(a);      // Move assignment operator

這里有幾點需要注意:

  • 這幾種函數的參數類型并不是固定的,比如Copy constructor的參數也可以是A& a,Move constructor的參數也可以是const A&& a。上面所寫的是編譯器默認生成的版本。我們也可以自己提供多個重載的版本。
  • 調用constructor還是assignment operator,取決于該語句構造了新的對象還是更新了原有對象的值。
  • 調用copy還是move,取決于參數是左值還是右值。
  • 上面只列出了直接構造或拷貝的使用場景。事實上,拷貝和移動語義更多地發生在函數調用期間,比如,函數參數為值傳遞,那么實參類型是左值還是右值就決定了調用拷貝構造還是移動構造。

那么,對于最后兩點,如何區分左值和右值呢?

答案是,靠經驗和直覺。別不信,看看文末參考資料中Value categories是怎么寫的就明白我的意思了。不過我來做個簡單的解釋,足夠你理解左值右值這一概念。

我們習慣于認為,C++中的表達式(這里的表達式是廣義的表達式,官方定義:an operator with its operands, a literal, a variable name, etc)分為兩種,左值lvalue和右值rvalue。其中,左值源自于“等號左邊的值”。能夠放在等號左邊,說明它其實是一個標識符,標記著某個對象。也說明它具有地址,可以用&取出該表達式所表示的對象的地址。而右值源自于“等號右邊的值”,不能取地址,通常是個臨時對象,用于初始化其它變量。

但是!實際上表達式不只有這兩種類型,嚴格來講,表達式有三種類型——lvalue、prvalue(pure rvalue)和xvalue(eXpiring value)。lvalue與我們通常認為的左值一致。prvalue與我們通常認為的右值一致。而xvalue則是C++11引入的右值引用。expiring value意思是“即將過期的值”,是對右值引用恰如其分的描述。右值引用既然是一個引用,那么它必然是通常意義上的左值,但又由于它引用的是一個右值,目的只是為了重復利用右值的資源,它是稍縱即逝的,所以單獨把它歸為一類。

這里需要提醒大家的一點是,左值、右值并不是與引用、非引用一一對應的。如果我們仔細回想,會發現左值引用一定是左值,非引用可以是左值也可以是右值,右值引用與非引用一樣,可以是左值也可以是右值。怕大家混淆概念,請看下面這個表格:


為了加深理解,再舉個例子,接著上面的代碼:

A&& a6 = std::move(a);  // Bind a6 to an rvalue moved from a.
A&& a7 = a6;            // Error! Can not bind a7 to an lvalue.
A&& a8 = std::move(a6); // Bind a8 to an rvalue moved from a6.

這三行代碼,第一行創建了一個右值引用a6,第二行試圖把右值引用a7綁定到a6,第三行試圖把右值引用a8綁定到move后的a6。第二行編譯出錯。用上面的表格很容易解釋這件事情,第二行的a6是右值引用,但它此時是作為顯式聲明的變量,所以它是個左值,也就不能被綁定到a7上。而第三行的std::move(a6)的返回值是個右值引用,而且是個臨時變量,所以它是個右值,也就可以被綁定到a8上。

在進入正題前,我們再看一個移動構造函數的具體實現,方便大家理解move語義存在的價值。

class MyVector {
    int* data_;
    size_t size_;

public:
    MyVector(): size_(100) {
        data_ = new int[size_];
    }

    ~MyVector() {
        delete[] data_;
    }

    MyVector(const MyVector& my_vector) {                                       // Copy constructor
        size_ = my_vector.size_;
        data_ = new int[size_];
        std::copy(my_vector.data_, my_vector.data_ + my_vector.size_, data_);
    }

    MyVector(MyVector&& my_vector) {                                            // Move constructor
        size_ = my_vector.size_;
        data_ = my_vector.data_;
        my_vector.size_ = 0;
        my_vector.data_ = nullptr;
    }

    // Should define copy assignment operator here

    // Should define move assignment operator here
};

MyVector my_vector;
MyVector my_vector1 = my_vector;                                                // my_vector is lvalue, thus copy constructor is invoked.
MyVector my_vector2 = std::move(my_vector);                                     // std::move(my_vector) is rvalue, thus move constructor is invoked.

這里,我們實現了一個簡單的數組類,自定義了拷貝構造函數和移動構造函數。在拷貝構造函數中,把舊data_數組中的每個元素依次賦值到新的對象中。在移動構造函數中,直接把舊data_數組的指針賦值給新對象,從而避免了數據的拷貝。但移動后,需要把舊對象的size_標記為0,把data_指針置空,以表示所有權的轉移。這個簡單的例子揭示了移動語義存在的價值,因為有些情況下,數據是可以轉移所有權的,而不必拷貝一份。

在更進一步之前,請大家務必理解上面的內容,這是一切關于右值引用和move語義的基石。

現在,預備知識到此為止,準備邁入新世界的大門吧。

初識std::move與std::forward

先拋出一句真理,請讀者牢記在心:“std::movestd::forward不在運行期做任何事情。”也就是說,編譯成機器碼后,這兩個函數是沒有代碼的,它們只是在編譯時做了一些非常非常簡單的操作。百聞不如一見,我們直接看看std::move的實現便知:

decltype(auto) move(T&& param)
{
    using ReturnType = remove_reference_t<T>&&;
    return static_cast<ReturnType>(param);
}

這是C++14版本的實現,使用了通用引用T&&作為參數類型,decltype(auto)自動推導返回值類型。簡單解釋一下,通用引用T&&可以接受任何類型的參數,且自動適配左值和右值。如果實參是左值,param就被推導為左值引用,如果實參是右值,param就被推導為右值引用。至于為什么會有這種效果,在后面“引用折疊”的部分將會詳細解釋。remove_reference_t<T>是一個標準庫函數,它會去掉T中包含的引用修飾符,比如把int&變成int,把const std::string&變成const std::string。如果你還不熟悉類型推導,請先移步《模板類型推導與auto》學習,否則接下來的內容會很不友好。

總而言之,std::move函數只做了一件事,修改param所持有的修飾符,并返回。無論輸入的參數是什么類型,是左值還是右值,統統轉換成右值引用并返回。這就是std::move所做的事情。

std::move好像什么都沒move...”

恭喜你,理解到這一層,離真相就不遠了。

所謂move語義,強調的是語義,而不是實實在在的東西。一個對象,它是左值還是右值,其實根本不影響它在內存中的存儲形式,只產生C++語法層面的影響。而C++語法層面的影響,是由編譯器來承受的。比如,同樣是一行賦值語句,如果等號右邊是左值,編譯器就會調用拷貝賦值運算符,如果是右值,就會調用移動賦值運算符。

那么真相是什么?std::move相當于告訴編譯器,現在,我的返回值是一個右值,無論它之前是什么,請務必按照右值的規則來對待它。類似的,std::forward則是告訴編譯器,我的返回值既可能是左值,也可能是右值,當傳給我的參數是右值引用時,我返回右值,否則我就返回左值。

區分通用引用與右值引用

讀者可能讀到這里已經有些暈頭轉向了,特別是前面提到的通用引用T&&,看起來明明是右值引用嘛。因此本節特地來解釋一下,如何區分通用引用與右值引用。

先舉幾個例子,看看這些T&&分別代表什么:


Widget&& var1 = Widget();       // rvalue reference

auto&& var2 = var1;             // universal reference

template<typename T>
void f(std::vector<T>&& param); // rvalue reference

template<typename T>
void f(T&& param);              // universal reference

template<typename T>
class vector {
    void push_back(T&& x);      // rvalue reference
}

總結起來其實就是這么兩點:

  • 如果T不是模板參數,而是具體的類型,T&&一定是右值引用。
  • 如果T是模板參數,T&&一般情況下是通用引用。除了下面兩種例外情況:
    • 如果T是模板參數,但param的類型不直接是T,比如std::vector<T>&&,那么std::vector<T>&&是右值引用。
    • 如果T是模板參數,但不需要自動推導,比如已經在類實例化的時候手動指定過了,那么T&&是右值引用。

用std::move處理右值引用,用std::forward處理通用引用

在前面幾節,我們努力地理解什么是右值,什么是右值引用和通用引用。但始終沒有回答這樣一個問題:“什么時候我應該使用右值引用?如何使用?”本節就來回答這個問題。

當我們設計一個函數的時候,我們一般如何決定參數類型?以我的經驗,如果參數只讀不寫,那么我一般會設計為const T&的形式;如果參數既讀又寫,那么我會設計為T&;如果函數內部想要對參數的副本進行操作,或者參數是基本數據類型,那么我會設計為T。如果參數是個臨時對象,又想避免拷貝的代價,那就得使用右值引用T&&了,這便是右值引用存在的價值。注意,細心的同學可能會記得,const T&也可以綁定臨時變量。但對于函數的設計者來說,我們無從得知實參的真實類型,所以我們無權復用const T&中的資源。只有當參數聲明為T&&后,調用者和函數雙方相當于達成了共識——參數的所有權移交給函數方,函數內部可以隨意“挪用”參數中的資源。

現在,假設我們作為函數的設計者,看看如何設計一個參數為T&&的函數。一個很常見的場景是矩陣運算,如果我們自己實現一個矩陣類,那么+運算符是必不可少的。但+運算符的參數如何設計則是一個大問題。我們可能會設計成這樣:

Matrix operator+(const Matrix& lhs, const Matrix& rhs) {
    Matrix sum = ...        // Sum all elements in lhs and rhs here.
    return sum;
}

參數類型設為const Matrix&避免了參數拷貝,返回局部變量也很合理,可以借助編譯器RVO優化避免創建臨時變量(關于RVO優化的詳細知識,參考文末的《Copy elision》)。一切都很完美,但唯一美中不足的是需要創建一個額外的Matrix對象。當然,這是由于返回值類型是非引用類型,按值返回可以避免對操作數造成污染,合情合理。

不過,考慮一種情況,如果其中某一個操作數是右值,這意味著調用者不會再去使用它,我們便可以復用它的內存空間,不必再創建新的Matrix對象。就像下面這樣:

Matrix operator+(Matrix&& lhs, const Matrix& rhs) {
    lhs = ...               // Sum all elements in lhs and rhs and assign to lhs.
    return std::move(lhs);
}

該函數用于左操作數是右值的情況,我們把std::move(lhs)返回,這是一個右值,它會觸發返回對象的移動構造函數,從而避免拷貝構造。

這里有個非常令人迷惑的地方,如果把return std::move(lhs)改成return lhs會怎樣?答案是,會導致返回對象的拷貝構造。雖然std::move的返回值類型與lhs的類型是完全一樣的,都是Matrix&&,但根據前面的預備知識,這里的std::move(lhs)是右值,而lhs是左值,因為std::move(lhs)是個臨時對象,而lhs是個顯式聲明的變量。

現在,operator+有了兩個重載的實現,前者適用于一般情況,后者適用于左操作數為臨時對象的情況,使用效果如下:

Matrix sum1 = m1 + m2;                  // 調用第一種實現
Matrix sum2 = Matrix::Identity() + m2;  // 調用第二種實現
Matrix sum3 = std::move(m1) + m2;       // 調用第二種實現

接下來,我們可以想得更遠一點,能不能把兩個函數合并成一個函數呢?畢竟為了這樣簡單的目的重載函數有些麻煩。不過,矩陣加法不太適合這種用法,我們換一個例子。

比如我們想要實現一個創建智能指針的工廠方法,輸入某個類T的構造函數所需的參數,返回T的智能指針。如何以最低的代價、最高的通用性來實現這個功能?C++標準庫給出了如下答案:

template<typename T, typename... Args>
std::unique_ptr<T> make_unique(Args&&... args)
{
    return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}

這里,T是需要構造的模板類,ArgsT的構造函數所需的參數的模板類型。問題又來了,使用new T(std::forward<Args>(args)...)和直接使用new T(args...)的區別是什么?答案是,前者會完美轉發args的實際類型(保留其lvalue和rvalue性質),而后者始終按照lvalue看待。結果很明顯,如果T的構造函數提供了支持lvalue和rvalue的多個重載,那么使用std::forward的方式會避免額外的內存拷貝。

最后,總結一下。對于函數的設計者來說,

  • 如果你想要復用參數,請將函數參數設為右值引用,并在函數中使用std::move處理參數后再返回。
  • 如果你想要完美轉發參數,請將函數參數設為通用引用,并使用std::forward<T>處理待轉發的參數。

不要濫用通用引用

通用引用很強大,但強大的東西往往也會帶來弊端。本節介紹使用通用引用導致出錯的情況,雖然不常發生,但了解這些情況可以幫助我們及早規避這些錯誤。

比如下面這個例子,向數組中添加字符串。你可能會想到使用通用引用來避免字符串拷貝:

std::vector<std::string> names;

template<typename T>
void add(T&& name) {
    names.emplace_back(std::forward<T>(name));
}

這樣的確能達到目的,當實參是右值時,emplace_back會調用移動構造函數,當實參是string literal時,emplace_back會直接在vector內部原地構造string。現在,出于某種需求,我們想要重載add函數,支持添加指定索引的name,比如:

std::string nameFromIdx(int idx);           // return name corresponding to idx

void add(int idx) {
    names.emplace_back(nameFromIdx(idx));
}

此時,如果我們用非int的數值類型作為參數,本來期望會調用第二個重載,但實際調用的卻是第一個:

long idx = 10l;
add(idx);                                   // invoke T&& overload

這是因為通用引用的匹配范圍實在是太大了,long類型的參數對于int idx重載來說不是準確匹配,而對于T&&的重載來說卻是準確匹配。

對此問題,作者的觀點是,避免重載通用引用。如果必須重載,有一些比較復雜的方案可以解決這個問題,比如tag dispatch設計模式。本文不再詳細介紹這些解決方案,在我看來,這是治標不治本的事情。

這一問題的出現,其根本原因是我們濫用了通用引用。add函數只是用來把string添加到數組里面,本質上,它只應該接收string類型的參數,即使是int idx的重載,也是為了換一種方式傳入string參數。而我們為了節約性能,直接把參數擴大到了可以接收任意類型的地步,這使得函數簽名失去了對函數使用者的約束,是一種嚴重的設計失誤。所以,我本人對該問題的看法是,不要嘗試為了性能而濫用通用引用。通用引用,由于其模板特性,只應該用在適合使用模板的地方。

通用引用的內部機制——引用折疊(reference collapsing)

本節繼續討論通用引用。事實上,“通用引用”并不是C++官方概念,而是Effective C++系列作者Scott Meyers自己發明的概念,是為了方便讀者理解而起的名字。那為什么官方不叫這個名字呢?因為它本質上其實就是右值引用,只不過在引用折疊的作用下,展示出了通用引用的效果。

我們來看看什么是引用折疊。通常,我們把左值引用綁定到左值上,把右值引用綁定到右值上,都是把引用綁定到值上。那如果把引用綁定到另一個引用上會怎樣呢?比如:

typedef int&  lref;
typedef int&& rref;
int n;
lref&  r1 = n;                              // r1 is a lvalue reference to lvalue reference, type of r1 is int&
lref&& r2 = n;                              // r2 is a rvalue reference to lvalue reference, type of r2 is int&
rref&  r3 = n;                              // r3 is a lvalue reference to rvalue reference, type of r3 is int&
rref&& r4 = 1;                              // r4 is a rvalue reference to rvalue reference, type of r4 is int&&

r1r4這四個變量都是引用綁定到引用,可以總結出一個規律:

  • 右值引用綁定到右值引用,結果還是右值引用;
  • 其它情況結果都是左值引用。

知道了這個規律,再來看通用引用,就可以發現,如果T被推導為左值引用,那么T&&就相當于把右值引用綁定到左值引用,結果是左值引用;如果T被推導為右值引用,那么T&&就相當于把右值引用綁定到右值引用,結果是右值引用。

可是為什么T會被推導為左值引用呢?書中沒有給出答案。我的解釋是,當實參為左值引用時,T只能被推導為左值引用,因為其它類型都不合法。但當實參為右值引用時,T被推導為非引用類型或者右值引用都合法,編譯器選擇了形式更為簡單的前者。

別對move期待過高

move的出現,為C++代碼提供了一種更低代價的構造和拷貝機制。但C++語法不是萬能的,move是否真的能夠提高性能,其實更取決于類的實現。

一個良好實現的類,通常需要手動實現這五種內存控制函數:拷貝構造函數、拷貝賦值運算符、移動構造函數、移動賦值運算符和析構函數。如果直接采用編譯器自動生成的版本,就很難達到最大的性能節約。以C++ STL容器類為例,std::vector是一個良好實現的類,下面的代碼就可以體現出拷貝構造和移動構造的性能差異:

std::vector<int> v(100000000, 0);               // Create a vector with 100000000 elements
std::vector<int> v1 = v;                        // Copy construct v1 by v, 0.185 seconds used.
std::vector<int> v2 = std::move(v);             // Move construct v2 by v, 6.748e-06 seconds used.

其中,拷貝構造和移動構造的耗時是在我的Core i5筆記本上測得的。而且,數組長度越長,v1的拷貝構造用時越長,但v2的移動構造用時不變。這是因為std::vector的拷貝構造函數需要依次拷貝每一個元素,因此是O(n)的時間復雜度。而std::vector的移動構造函數只需要把舊vectordata指針拷貝給新vector即可,因此是O(1)的時間復雜度。

但并非所有的容器類都能從move中受益,看看下面這個例子:

std::array<int, 100000> arr;                    // Create an array with 100000 elements
std::array<int, 100000> arr1 = arr;             // Copy construct arr1 by arr, 0.155 seconds used.
std::array<int, 100000> arr2 = std::move(arr);  // Move construct arr2 by arr, 0.148 seconds used.

首先聲明,這里測的耗時放縮了一定的比例,因為std::array的長度受棧空間限制,不能聲明的太大。所以上面這段代碼需要循環執行很多次才能得到比較穩定的耗時結果。從結果中可以看到,拷貝構造和移動構造用時接近。這是因為std::array的數據是在棧上申請的臨時空間,無法把一個array的數據通過指針賦值給另一個array,只能逐元素拷貝。所以無論是拷貝構造還是移動構造,用時都隨著元素增多而增加,時間復雜度是O(n)。但事情到這里還沒完,雖然時間復雜度沒區別,但單個元素的拷貝,move仍然可能快于copy,看下面這種情況:

class MyVector {                                                // A customized vector class
    vector<int> data;

public:
    MyVector() {
        data = vector<int>(100, 0);
    }
};

std::array<MyVector, 10000> my_array;                           // Create an array with 10000 elements
std::array<MyVector, 10000> my_array1 = my_array;               // Copy construct my_array1 by my_array, 0.0018 seconds used.
std::array<MyVector, 10000> my_array2 = std::move(my_array);    // Move construct my_array2 by my_array, 0.0001 seconds used.

與前一個例子的區別是,這里array中存的是自定義的數據類型MyVectorMyVector雖然沒有顯式定義移動構造函數,但編譯器為它生成了一個。所以array的移動構造函數就會調用每一個元素的移動構造函數,從而在構造每個元素時節約一部分時間。

這幾個案例告訴我們,move并不是萬能的。軟件開發是一個上下游協作的過程。每一個類的移動構造都可能依賴于上游代碼庫,這沒關系,我們只要保證在我們這一層做了最大的優化,那么下游用戶就可以放心使用我們的類。所以,作為類的開發者,應該使自己的類盡量提供拷貝構造和移動構造這兩種方式,并在移動構造中體現出應有的移動語義,比如拷貝指針而不是拷貝數據。

參考資料

Copy constructors cppreference
Copy assignment operator cppreference
Move constructors cppreference
Move assignment operator cppreference
Value categories cppreference
Return forwarding reference parameters - Best practice stackoverflow
C++ Core Guidelines: The Rules for in, out, in-out, consume, and forward Function Parameter
C++模板進階指南:SFINAE
Copy elision cppreference

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,517評論 6 539
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,087評論 3 423
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,521評論 0 382
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,493評論 1 316
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,207評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,603評論 1 325
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,624評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,813評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,364評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,110評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,305評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,874評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,532評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,953評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,209評論 1 291
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,033評論 3 396
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,268評論 2 375

推薦閱讀更多精彩內容