1. 程式人生 > >【強文翻譯】c++右值引用詳解

【強文翻譯】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小節中的工廠函式的例子。