【強文翻譯】c++右值引用詳解

原文鏈接

譯註:

這篇是我讀過有關右值引用的文章中最通俗易懂的一篇,易懂的同時,其內容也非常全面,所以就翻譯了一下以便加深理解。有翻譯不準的地方請留言指出。


INTRODUCTION

右值引用是C++11標準中引入的新特性,由於右值引用所解決的問題並不是很直觀,所以很難在一開始就很好的理解這一特性。因此,本文不試圖在一開始直接去解釋右值引用是什麼,而是介紹一些待解決的問題,從而演示右值引用在解決這些問題中所起到的作用。通過這種方式讓你更直觀、自然的理解什麼是右值引用。

 

右值引用的應用範圍至少包括以下兩類問題:

1. 實現move語義

2. 完美轉發

 

接下來分別介紹這兩個問題。

首先是move語義,在介紹move語義之前,我們首先回顧一下c++中的左值和右值。嚴格地給出左值和右值的定義並不容易,考慮到本文主要關注點在於右值引用,我們對左值和右值給出一個相對簡單的解釋。

C語言中對左值和右值的原始定義是:

如果表達式e可以出現在賦值語句的左手邊和右手邊,則e是一個左值,如果只能出現在賦值語句的右手邊,那麼e是一個右值。比如:

int a = 42;
int b = 43;

//a, b均爲左值,那麼
a = b;
b = a;
a = a * b;
//均爲合法語句

//a * b 是右值
int c = a * b;//合法,右值出現在賦值語句右手邊
a * b = 42; //不合法,右值出現在了賦值語句左手邊

在c++中,憑直覺按這一定義去理解左值和右值也是可以的,但是,由於c++中存在着用戶自定義類型,在可修改性和可賦值性上與c語言不盡相同,這也導致此定義再適用。下面我們給出一個定義,這一定義同樣不夠嚴謹,但對於理解右值引用而言已經足夠:

有一個表達式(如 i, 5, 3* 4等)指向某一內存空間,如果我們可以通過取址運算符(&)取得其指向的內存地址,那麼該表達式是一個左值,否則該表達式爲右值。例如:

//左值i,以下語句均合法
int i = 42;
i = 43;
int* p =&i;
int&foo();
foo() = 42;
int* p1 =&foo();

//右值
int foobar();//foobar()爲右值
int j = 0;
j = foobar();//合法,右值可以出現在賦值語句右側
int* p2 =&foobar(); //不合法,不可對右值做取址操作
j = 42; //合法,42是右值,可以出現在右側 


MOVE SEMANTICS

假設有一個類X,該類中聲明瞭一個指向其它資源的指針,設爲m_pResource。上述的資源是指一些構造、析構、複製操作較爲耗時的類(如std::vector)對象。對於類X,它的拷貝賦值操作符的定義有如下形式:

X& X::operator=(const X & rhs)
{
    ...
    //複製rhs.m_pResource指向的內容
    //析構this->m_pResource指向的內容
    //令this->m_pResource指向複製的內容
    ...
}

構造函數與賦值操作類似。然後我們來看下面的程序段:

X foo();
X x;
//...
//對x進行了一系列操作
//...
x = foo();

最後一行中

  • 複製了函數foo()返回的臨時變量中的資源
  • 析構了x.m_pResource指向的資源,並使其指向複製的資源
  • 析構臨時變量,同時釋放其資源。

很明顯,如果能夠直接交換x和臨時變量的資源指針,能夠更高效的得到正確的結果,由臨時變量的析構函數去析構原來x指向的資源。

換句話說,當賦值語句右手邊是一個右值時,我們希望X的拷貝賦值操作符是這樣的:

X& X::operator=(const X & rhs)
{
	...
	//交換rhs.m_pResource與this->m_pResource
	...
}

這一語義即爲move語義。

在C++11中,針對某一特殊情況進行特殊操作這一行爲可以通過重載實現:

X& X::operator=(<特殊類型>  rhs)
{
    ...
    //交換rhs.m_pResource與this->m_pResource
    ...
}

既然我們需要對賦值運算符進行重載,那麼首先“特殊類型”必需是一個引用類型,因爲出於效率的考量,我們更希望參數是以引用的形式傳入的。其次,我們希望這一“特殊類型”有這樣的性質:當有兩個重載函數分別接受引用類型和“特殊類型”作爲參數,那麼,左值參數必須調用前者,而右值參數必須調用後者。

將上述的“特殊類型”替換爲右值引用,即爲右值引用的定義。

 

RVALUE REFERENCES

如果X表示一個類型,那麼X&&叫做類型X的右值引用。爲了便於區分,原來的引用類型X&也稱作左值引用。

右值引用與左值引用的行爲很類似,但有幾點區別。其中最重要的一點是,當存在重載函數時,左值參數優先調用左值引用函數,右值參數優先調用右值引用函數:

 

//重載函數
void foo(X& x);
void foo(X&& x);
 
X x
X foobar();
 
foo(x); //調用foo(x : X&)
foo(foobar());//調用foo(x : X&&) 

綜上,右值引用的主旨是:

允許編譯器根據參數在編譯時決定是否使用左值函數或右值函數。

 

我們可以對任何函數進行這樣的重載,但是絕大多數情況下,我們只會在拷貝賦值操作符和複製構造函數中使用右值引用重載,即爲了實現move語義:

X& X::operator=(X&& rhs)
{
    //Move語義:交換this和rhs的內容
    return *this;
}

右值引用重載複製構造函數的實現類似。

注:

  • 如果實現了foo(X&x),但未實現foo(X&& x),那麼左值可以調用該函數,右值無法調用;
  • 如果實現了 foo(constX& x),但未實現foo(X&&x),那麼左值和右值均可調用該函數,但編譯器無法區分傳入的參數是左值或右值。
  • 如果實現了foo(X&&x),但未實現foo(X& x) 或 foo(const X& x),那麼右值可以調用該函數,左值會引發編譯錯誤。


FORCING MOVE SEMANTICS

衆所周知,在c++標準第一修正案中有這樣一句話:“委員會不應該制定任何規則去阻礙程序員們搬石砸腳”。正經一點說,就是在給程序員更多的可控性和減少程序員大意犯錯造成的影響這兩者中,C++更傾向於前者,即使這看起來更不安全。遵循這樣的準則,c++11不僅允許程序員在右值上使用move語義,在任何需要的時候,也可以在左值上使用。一個比較好的例子是標準庫中的swap函數。類似的,假設X是一個類,該類重載了拷貝構造函數和拷貝賦值操作符對右值實現了move語義。

template<classT>
void swap(T& a, T& b)
{
    T tmp(a);
    a = b;
    b = tmp;
}
 
X a, b;
swap(a, b);

上述程序段中沒有右值,因此,swap函數中的三行均未使用move語義,但顯然move語義是可以應用在這裏的:一個變量作爲拷貝構造或賦值運算的源操作數,且該變量後續不再使用,或僅被用於賦值操作的目的操作數。

在c++11中,標準庫提供了一個函數std::move來滿足我們的需求。該函數將其參數轉換爲右值返回,沒有任何其它操作。此時,c++11標準庫中的swap函數變成如下形式:

template<classT>
void swap(T& a, T&b)
{
    T tmp(std::move(a));
    a = std::move(b);
    b = std::move(tmp);
}

上面swap函數中的3行均使用了move語義。注意如果類T未實現move語義,即拷貝構造函數和拷貝賦值操作符沒有右值引用重載,那麼swap函數與之前的版本運行上沒有區別。

std::move是一個非常簡單的函數,但我們現在暫時不去關注它的具體實現,在後面的章節再展開討論。

類似上述swap函數,儘量多的的使用std::move函數能給我們帶來很多的好處:

  • 對於一些實現了move語義的類型,很多的標準庫函數、操作符會使用move語義,這有可能大幅提升程序性能。一個明顯的例子是就地排序,就地排序函數中最主要的操作就是交換元素,因此對於提供了move語義的類型,該函數能夠獲得巨大的性能提升。
  • STL庫經常要求模板參數類型可複製,比如用作容器元素的類型。仔細的檢查一下,可以發現在很多情況下,類型只要可移動(moveable)即可以滿足要求。因此,我們可以在STL庫中使用一些可移動但不可複製的類型(例如unique_pointer),比如將其作爲STL容器的元素等,這在以前是不被允許的。


瞭解了std::move之後,我們來回顧一下之前提出的問題,有關使用右值引用對拷貝構造函數和拷貝賦值操作符的重載有哪些問題。來看下面一個簡單的賦值語句:

a = b;

當寫出這個語句時,我們期望變量a指向的對象被b所指向對象的拷貝所代替,在這個替代的過程中,我們期望a原來指向的對象被析構。那麼對於下面這一行:

a =std::move(b);

如果move語義的實現是一個簡單的交換操作,那麼這一操作的結果是a和b所指向的對象相互交換,並沒有對象在這一過程中被析構。a原本指向的對象在最終當然會在b的生命週期結束時被析構,但前提是b在後續過程中不作爲move的源操作數,因爲這會使得這一對象再次被交換。因此,目前爲止的拷貝賦值操作符的實現中,我們無法得知a原來指向的對象何時會被析構。

在某種意義上說,我們進入了一個不確定性析構的危險區域:對一個變量進行了賦值,但這個變量之前存儲的對象還存在於某一個位置。當對象的析構函數對其對象外的空間沒有影響時,這樣的情況不會遇到問題,但有的析構函數則會產生這樣的負面影響,比如析構函數中需要釋放一個同步鎖。因此,在拷貝賦值操作符的右值引用重載中,對象的析構函數中所有可能有負面影響的部分都應該被顯式執行。

X& X::operator=(X&& rhs)
{
    //執行一些步驟,保證此對象在之後可隨時析構和賦值而不產生負面影響
    //move語義
    return *this;
}


IS AN RVALUE REFERENCE AN RVALUE

同上,設X是一個類,該類重載了拷貝構造函數和拷貝賦值操作符以實現move語義。對於下面的程序段:

void foo(X&& x)
{
    X anotherX = x;
}

一個有趣的問題:在foo的函數體內,哪個拷貝構造函數的重載會被調用?x是一個被聲明爲右值引用類型的變量,一般是指向一個右值的引用,因此,認爲x本身也是一個右值是一個合理的想法,也就是說應該調用X(X&& rhs),換句話說,人們很容易認爲一個變量被聲明爲右值引用,那麼它本身也是一個右值。

右值引用的設計者們選擇了一個更微秒的規則:被聲明爲右值引用的變量,其本身可以是右值也可以是左值,區分的準則是:如果該變量是一個匿名變量,那麼是右值,否則是左值。

在上面的例子中,被聲明爲右值引用的x有名字,所以是左值。即在foo中調用的函數是X(const X& rhs)。

下面是一個匿名右值引用變量的例子:

X&& goo();
X x = goo();//此時會調用X(X&& rhs)

設計者們使用這一方案的初衷是:讓move語義應用於非匿名變量時能夠符合人們的使用習慣。比如,令 x是一個X類對象的右值引用,那麼對於下面的語句:

X anotherX = x;

此時,如果x是一個右值,我們將會執行move語義,此後,x所指向的變量已經被移動,但由於x的生命週期尚未結束,後續對x的使用極易出錯。我們使用move語義顯然應該在不會因此出錯的前提之下,即被移動的變量在其內容被移動後生命週期立即結束並析構。因此有了這樣的規則:如果一個變量不是匿名變量,那麼他是一個左值。

接下來我們看規則的另外半句:“如果是匿名變量,那麼他是一個右值”。在上面goo函數的例子中,表達式goo()指向了一個內容,這一內容在賦值操作後被移動。理論上來說,被移動的變量仍然是可能被獲取的(比如一個全局變量,顯然這是一個左值)。回想上一小節的內容,有時這恰好是我們的需求:我們希望能夠在必要的時候強制對左值變量使用move語義,上述“匿名變量是右值”的規則能夠讓我們自由地控制這一特性。

這也是std::move的機制。現在去詳述std::move的機制仍然有一些早,但我們對它的理解又加深了一些:std::move將其參數以引用的方式傳遞,不對其做任何其它操作,函數的返回值是一個右值引用。所以,表達式std::move(x)聲明瞭一個右值引用,又因爲它是匿名的,所以它是一個右值。綜上,std::move“可以將其非右值參數轉換爲右值”,其實現的方式是“匿名”。


下面我們觀察一個例子來更好地理解“是否匿名規則”的重要性。

假設有一個類Base,我們通過重載其拷貝賦值操作符和拷貝構造函數爲該類實現了move語義:

Base(constBase& rhs);
Base(Base&& rhs);

然後,實現Base類的子類Derived。爲了保證Derived類對象中繼承自Base的部分使用move語義,我們必須重載Derived類的拷貝賦值操作符和拷貝構造函數。子類拷貝構造函數的實現與父類類似,左值版本很簡單:

Derived(const Derived& rhs) : Base(rhs)
{
    //Derived類中的擴展內容
}

但右值拷貝構造函數會變得微妙得多。如果對“是否匿名規則”理解不夠,那麼可能會實現出下面的版本:

Derived(Derived&& rhs) : Base(rhs) //!!錯誤,rhs是一個左值
{
//Derived類中的擴展內容
}

上述實現中,基類拷貝構造函數將的左值重載會被調用,因爲非匿名變量rhs是一個左值。但我們實際的需求是希望調用基類拷貝構造函數的右值重載,所以正確的實現方式應該是:

Derived(Derived&& rhs) : Base(std::move(rhs)) // 將會調用Base(Base&& rhs)
{
    //Derived類中的擴展內容
}


MOVE SEMANTICS AND COMPILER OPTIMIZATIONS

對於下面的函數定義:

X foo()
{
    X x;
    //... 對x進行操作 ...
    return x;
}

與之前章節相同,類型X實現了move語義。如果僅從上面的實現來看,你也許會覺得這個函數中發生了一次對象值的拷貝:從變量x到函數返回的對象。出於對函數性能的考慮,你也許會對這個函數進行這樣的“優化”:

X foo()
{
    X x;
    //... 對x進行操作 ...
    return std::move(x); //強制move語義
}

然而,這樣的修改只會讓函數的執行變慢。原因是,現在的編譯器會對原函數進行返回值優化。換句話說,編譯器會直接在函數返回值的位置創建變量,而不是創建一個局部變量然後在返回時將其複製到返回值的位置。很明顯,這甚至比move語義更加高效。

從上面的例子可以看出,如果你想更好的使用右值引用和move語義,首先要全面的理解它們,同時還需要考慮編譯器的“特殊影響”,比如返回值優化,省略複製(copy elision)。詳細內容可以參考Scott Meyer的《Effective Modern C++》。對於它們的理解非常繁瑣,但這也是C++的魅力所在。自己選擇的路,跪着也要走完~


PERFECT FORWARDING: THE PROBLEM

除了move語義,右值引用所解決的另一個問題是完美轉發(perfect forwarding)。例如下面的工廠函數(factory function):

template<typename T, typename Arg>
shared_ptr<T> factory(Arg arg)
{
    return shared_ptr<T>(new T(arg));
}

顯然,這個函數的目的是將參數arg轉發到T的構造函數中。對於對象arg而言,最理想的情況是,所有的操作如同不存在這個工廠函數,直接使用arg調用了T的構造函數,這就是完美轉發。上面的函數完全沒有達到這一要求:通過值傳遞參數,不僅效率低下,當構造函數是引用傳參時,還容易引發bug。

最常見的做法是讓外層函數通過引用傳遞參數:

template<typename T, typename Arg>
shared_ptr<T> factory(Arg& arg)
{
    return shared_ptr<T>(new T(arg));
}

這樣情況有所改進,但仍然不完美。因爲這樣的版本中,右值無法調用這個函數:

factory<X>(hoo());//如果hoo通過值返回,則無法調用
factory<X>(41);

通過常引用重載可以解決這個問題:

template <typename T, typename Arg>
shared_ptr<T> factory(const Arg& arg)
{
	return shared_ptr<T>(new T(arg));
}

但這一版本同樣面臨兩個問題:首先,如果這個函數不是一個參數,而是多個,那麼需要重載所有參數的const/non-const引用的組合,顯然不是很好的方法。其次,這樣的方案還不夠完美,因爲它阻礙了move語義:T的構造函數接受的參數是左值,即使類T有實現move語義的拷貝構造函數,也不可能在函數factory中被調用。

右值引用則可以解決上述兩個問題。通過右值引用可以實現真正的完美轉發而不需要使用重載。爲了更好的理解如何實現,我們首先需要了解兩條關於右值引用的規則。


PERFECT FORWARDING: THE SOLUTION

第一個有關右值引用的規則與左值引用也有關係,在C++11以前的版本,不允許使用引用的引用,比如A&&會引發編譯錯誤。而c++11中,引入了引用摺疊規則(reference collapsing rules):

  • A& & = A&
  • A& && = A&
  • A&& & = A&
  • A&& && = A&&

第二個規則是:在模板函數中,如果函數形參類型是模板參數類型的右值引用類型:

template<typename T>
void foo(T&&);

那麼,有特殊模板參數推導規則,應用如下規則進行推導:

1.   如果實參類型是A且爲左值,那麼T推導爲A&,因此根據引用摺疊規則,形參類型爲A&。

2.   如果實參類型是A且爲右值,那麼T推導爲A,因此形參類型是A&&。

有了以上的規則後,我們可以使用右值引用來解決完美轉發問題:

template<typename T, typename Arg>
shared_ptr<T> factory(const Arg&& arg)
{
    return shared_ptr<T>(new T(std::forward<Arg>(arg)));
}

其中std::forward的實現如下:

template<class S>
S&& forward(typename remove_reference<S>::type& a) noexcept
{
    return static_cast<S&&>(a);
}

(第9小節會介紹關鍵字noexcept,現在只需要知道這一關鍵字告訴編譯器這個函數不會拋出異常,方便編譯器進行優化。)下面我們來分別考慮左值和右值調用該函數的情況,上述實現如何解決完美轉發問題。

設有類型A和X,假設函數factory<A>的實參爲X類型且爲左值:

X x;
factory<A>(x);

根據前述模板推導規則,函數factory的模板參數Arg被推導爲X&,此時,編譯器會展開模板函數factory和std::forward實例如下:

shared_ptr<A> factory(X& && arg)
{
	return shared_ptr<A>(new A(std::forward<X&>(arg)));
}

X& && forward(remove_reference<X&>::type& a) noexcept
{
	return static_cast<X& &&>(a);
}

經過std::remove_reference和前述引用摺疊規則,最終變成:

shared_ptr<A> factory(X& arg)
{ 
    return shared_ptr<A>(new A(std::forward<X&>(arg)));
}

X& std::forward(X& a)
{
    return static_cast<X&>(a);
}


這實現了對左值的完美轉發:參數arg經過兩次間接的左值引用被傳遞給A的構造函數。

接下來看X類型右值作爲實參的情況:

X foo();
factory<A>(foo());

同根根據模板推導規則,函數factory的模板參數arg被推導爲X,編譯器展開的函數實例如下:

shared_ptr<A> factory(X&& arg)
{
    return shared_ptr<A>(new A(std::forward<X>(arg)))
}

X&& forward(X& a) noexcept
{
    return static_cast<X&&>(a);
}

上面例子中的兩次參數傳遞都是通過引用,實現了完美轉發,除此之外,A的構造函數接受了一個匿名右值引用參數,根據之前介紹的“是否匿名規則”,A的構造函數將會調用右值重載。意味着沒有factory函數封裝時,如果A的構造函數會使用move語義,那麼上述實現的轉發可以保留move語義的使用。

在上面的應用中std::forward的唯一作用就是保留move語義的使用,雖然這可能沒有什麼價值。如果不使用std::forward,那麼上述的實現依然可以正常運行,但A的構造函數的實參只會是非匿名參數,即只有左值實參。換句話說,std::forward的作用是轉發外層封裝函數的實參是左值或右值這一信息。

如果想要了解得更深入一點,可以考慮一下這個問題:爲什麼std::forward函數中需要調用remove_reference?答案是,實際上完全不需要。如果在std::forward中,不使用remove_reference<S>::type&而是直接使用S&,重新推導一下上面的實現,可以發現依然能夠達到完美轉發,但是需要顯式地指定Arg爲std::forward的模板參數。remove_reference的作用即是強制我們去這樣指定。

現在,我們終於可以去看一下std::move的具體實現了,再強調一次:std::move的作用是通過引用將其接受的參數返回,並將其綁定到一個右值上。具體實現如下:

template<classT>
typenameremove_reference<T>::type&&
std::move(T&& a) noexcept
{
    typedef typenameremove_reference<T>::type&& RvalRef;
    return static_cast<RvalRef>(a);
}

如果我們對X類型的左值調用std::move:

X x;
std::move(x);

根據之前所說的特殊模板解析規則,std::move的模板參數爲X&,因此,編譯器將會爲我們實例化如下:

typename remove_reference<X&>::type&&
std::move(X&&& a) noexcept
{
    typedef typenameremove_reference<X&>::type&& RvalRef;
    retun static_cast<RvalRef>(a);
}

經過remove_reference和引用摺疊後:

X&&std::move(X& a) noexcept
{
    return static_cast<X&&>(a);
}

上面函數中,實參x將被綁定到一個左值引用中傳遞給函數,函數將其轉換爲一個匿名的右值會用並返回。

留下一個需要思考的問題:對右值調用std::move同樣沒有問題。另外,也許你已經發現除了調用std::move之外,可以直接使用

static_cast<X&&>(x)

來實現move的功能,但爲了可讀性,最好還是用std::move。

 PS: 本文介紹的引用摺疊並不完整,跳過了一些有關const, volatile修飾符的內容,如果有興趣,可以參閱Scott Meyer的《Effective Modern C++》 。


RVALUE REFERENCES AND EXCEPTIONS

一般來說,使用C++開發一個軟件,你可以決定是否花費精力去處理異常、或者是否在程序中使用異常控制。在這方面右值引用有些不同,當通過重載拷貝構造函數或拷貝賦值操作符實現move語義時,通常建議按如下方式:

  1.  試着讓你的實現無法拋出異常。這通常非常容易,因爲move語義一般只會在兩個對象間交換指針或資源句柄。
  2. 2. 當成功保證了你的實現不會拋出異常後,再通過使用noexcept關鍵字將這一信息顯示的傳遞出來。

如果沒有做這兩件事,你的move語義版重載很可能不會如你所願被調用,比如下面的情況:當一個std::vector進行resize時,我們顯然希望vector中的元素在被重分配時使用move語義,但如果1、2沒有被同時滿足,那麼編譯器不會使用move版本。

至於具體的原因,本文不會詳細介紹,只要記住以上兩個建議就足夠了。如果想要深入瞭解,可以參閱《Effective Mordern C++》中的第14條。

 

THE CASE OF THE IMPLICIT MOVE

在關於右值引用問題的討論(通常是複雜且有爭議的)期間,標準委員會曾決定,移動構造函數或移動賦值操作符(即使拷貝構造函數和拷貝賦值操作符的右值引用重載),應在用戶提供時由編譯器去自動生成。考慮到編譯器對原來的拷貝構造函數和賦值操作符就採取了這樣的策略,這看起來是很自然、很合理的需求。在2010年8月,Scott Meyers在comp.lang.c++上發佈了一條消息,解釋了編譯器自動生成的移動構造函數會嚴重破壞已有代碼的原因。

委員會認可了這一問題的嚴重性,然後對自動生成拷貝構造函數和拷貝賦值操作符的條件進行了限制,使現有代碼被破壞的可能性很小(仍然無法完全避免)。最後的結果在Scott Meyer的《Effective Modern C++》中的第17條中有詳細介紹。

隱式移動的問題直到標準定稿時依然存在爭議。諷刺的是,委員會優先考慮隱式移動的原因,僅僅是試圖解決第9小節所述的右值引用和異常問題。這一問題在之後通過使用新關鍵字noexcept得到了更合適的解決方案。如果不是幾個月前發現了noexcpet這一方法,隱式移動甚至可能永無出頭之日。

以上,就是有關右值引用的全部故事了。顯而易見,其收益是巨大的,但其細節則是殘酷的,如果c++是你的主業,你必須理解這些細節,否則你就放棄了對於這一工具的理解,而這是你的工作中心。值得慶幸的是,如果只是考慮每天的編程工作,那麼關於右值引用,你只需要記住以下三點:

1. 通過對函數進行如下形式的重載:

void foo(X& x);//左值引用重載
void foo(X&& x);//右值引用重載

你可以讓編譯器在編譯時確定是否使用左值或右值。最主要的應用是重載類拷貝構造函數和拷貝賦值運算符以實現move語義(實現move語義也是使用右值引用的唯一目的)。當你這樣使用時,要注意處理異常,並儘可能多的使用關鍵字noexcpet。

2. std::move將其參數轉換爲右值。

3. 通過std::forward可以實現完美轉發,如第8小節中的工廠函數的例子。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章