1. 程式人生 > >細談 C++ 返回傳值的三種方式:按值返回、按常量引用返回以及按引用返回

細談 C++ 返回傳值的三種方式:按值返回、按常量引用返回以及按引用返回

一、引言

停滯了很久,最近又開始細細品味起《Data Structure And Algorithm Analysis In C++》這本書了。這本書的第一章即為非常好的 C++11 統領介紹的教材範文,可能對於 C++11 新手來說,作者這樣短篇幅的介紹或許有些蒼白晦澀,但是對於我這種有一定 C++ 開發經驗並且有研讀過 《C++ Primer 5th》的人來說,作者這幾頁簡直就是讓我對 C++11 的整體脈絡有了更加巨集觀的認識。

話不多說,在之前,我看了這本書的第 1.5.3 節後,總結了一篇部落格:
細談 C++ 傳參的四種方式:按值傳參、按左值引用傳參、按常量引用傳參以及按右值引用傳參

可以說,我這篇部落格裡面那種畫出來的圖(什麼時候選擇使用哪種傳參方式)就是非常精煉的總結。

C++ 除了四種傳參方式,其實還有三種返回傳值的方式。C++ 的程式碼為什麼這麼難以理解,從入參到出參,這都是有一些門門道道的。

同樣的,這裡,我拿出這本書的第 37 頁的一塊函式模板的程式碼,你能解釋出來為什麼這個 findMax 函式要使用按常量引用的方式返回傳值嗎:

/**
 * Return the maximum item in array a.
 * Assumes a.size() > 0.
 * Comparable objects must provide operator< and operator=
 */
 template <typename Comparable>
 const Comparable & findMax(const vector<Comparable> & a)
 {
 	int maxIndex = 0;
	for(int i = 0; i < a.size(); ++i)
		if (a[maxIndex] < a[i])
			maxIndex = i;
	return a[maxIndex];
 }

這裡以這段程式碼開篇引言,後面會仔細介紹 C++ 的三種返回傳值的方式,以及總結一下什麼時候,我們該選擇使用哪種返回傳值的方式。

ps: 本篇部落格大量參考了《Data Structure And Algorithm Analysis In C++》書中的解釋和程式碼。

二、一點碎碎唸的討論

讓我們忘掉那麼多複雜的概念,我們一起來仔細思考一下,C++ 返回傳值的表現:被傳遞的值是否持續存在、傳遞過程中是否發生了拷貝、傳遞過程中是否會自動轉化成移動語義(C++11 新增)等等。這些表現到底與哪些因素有關呢?

通過仔細研讀《Data Structure And Algorithm Analysis In C++》書中的闡述,我認為 C++ 返回傳值的表現的不同,主要與兩方面有關係:

1. 被返回的值

函式體內,被返回的值,它是左值還是右值,是臨時變數(函式體內定義的)還是非臨時變數(函式體外定義的),都會影響到返回傳值的表現。

這裡,我簡單舉個例子:

LargeType randomItem1 (const vector<LargeType> & arr)
{
    return arr[randomInt(0, arr.size() - 1)];
}
vector<LargeType> vec;
// copy
LargeType item1 = randomItem1(vec);

這裡,vec 以按常量引用的方式傳入 randomItem1 函式,它是一個非臨時變數,並且是一個左值返回。那麼這次返回傳值的表現是什麼呢,那就是在返回後,arr 陣列實際上持續存在,並且因為 arr 實際上是一個函式外定義的變數,因此在函式內部傳值返回的時候,不可以使用 C++11 新增的自動右值轉化變成移動語義以避免複製拷貝開銷。

這是什麼意思呢,我們再來看一段程式碼就好了:

vector<int> partialSum(const vector<int> & arr)
{
	vector<int> result(arr.size());
	result[0] = arr[0];
	for (int i = 1; i < arr.size(); ++i)
		result[i]  = result[i - 1] + arr[i];
	return result;
}
vector<int> vec;
// copy in old C++, move in C++11
vector<int> sums = parialSum(vec); 

這段程式碼乍一看,彷彿並沒有什麼不同,也是普通返回型別,也是按常量引用傳值。可是這個呼叫的返回傳值的表現就大大不同了,傳統 C++ 確實還是會按值返回,發生拷貝,但是 C++11 中就會自動轉化為移動語義,使用移動替換拷貝節省開銷。

為什麼呢?這就是因為返回的 result 實際上是一個臨時變數,同右值在這裡有異曲同工之妙的地方在於,他們在離開了函式之後都會自動的消失,也就是說,我們將其值移動到返回接受這個值的地方完全沒有問題,並且還能節省資源開銷,何樂而不為呢?而 C++11 確實也是這麼做的。

總而言之,返回傳值的表現,與被返回的值有關係。其是臨時變數或者右值,就有拷貝轉移動的可能(返回型別要是普通型別),其是非臨時變數,則有返回後持續存在的特性。

2. 返回型別

函式定義的返回型別會影響返回傳值的表現,這個也不難理解。

一般來說,返回型別如果定義為:

  1. 按值返回,則要產生拷貝;

  2. 按常量引用返回,則如果呼叫方使用常量引用接受返回值則不產生拷貝

  3. 按引用返回,則既不產生拷貝,並且還能對其值進行修改(這種情況雖然少見,但是也有存在)。

這裡,我在介紹 C++ 的三種返回傳值之前先進行了一些討論,希望能夠對這三種返回傳值型別有一些初步的瞭解。

接下來,我們來結合著例子來詳細的探討下這三種返回傳值的方式。

三、這個標題才是正餐

有了前面的一些準備知識,我們來細細的探討下 C++ 返回傳值的三種方式。

1. 按值返回

按值返回,可能是初學者最熟悉的返回方式了。

LargeType randomItem1 (const vector<LargeType> & arr)
{
    return arr[randomInt(0, arr.size() - 1)];
}
vector<LargeType> vec;
LargeType item1 = randomItem1 (vec);

我拿出來了同樣的例子,我們聲明瞭 randomItem1 的返回型別為 largeType,並未加上 & 或者 const 這就是簡單的按值返回。我們將 vec 這個 vector 陣列按常量引用傳入 randomItem1 函式中去,目的是讓 vec 在函式內不至於被改變,並且還能避免傳參拷貝帶來的資源消耗。

在帶出引數的時候,我們發現,帶出來的 arr[randomInt(), arr.size() - 1] 其實上是一個非臨時變數的左值,在返回傳值的時候,需要拷貝給接受該值的 item1 變數。

說的很複雜,其實很簡單,那就是這裡發生了拷貝開銷。

前面也有提到過,在 C++11 一些情況下,拷貝可能會被自動轉化為移動。那麼這裡可以嗎?

答案是不行的。

能被轉化為移動語義的,一般都帶有臨時的語義,要麼是被返回的值是臨時變數,要麼就是一個臨時的右值,否則不能轉化為移動語義。

總的來說,那就是按值返回一般帶有拷貝的意味,只是 C++11 會進行一些優化,會將臨時的值的返回自動轉化為移動來節省拷貝開銷。

2. 按常量引用返回

既然按值返回會出現拷貝的開銷,那麼為了解決這個問題,加上一個引用不就好了嗎。因此,按(常量)引用返回的方式就應運而生。

顧名思義,常量,意味著不能對返回值進行修改,引用,則意味著避免拷貝開銷。

const LargeType & randomItem2(const vector<LargeType> & arr)
{
    return arr[randomInt(0, arr.size() - 1)];
}
vector<LargeType> vec;
// copy
LargeType item1 = randomItem2(vec);
// no copy
const LargeType & item2 = randomItem2(vec);

這裡值得注意的是,按常量引用返回的呼叫,需要同樣以常量引用去接受它,否則常量引用向引用型別的轉換(const 轉非 const)是需要拷貝轉換的。

另外,可能你已經注意到了,那麼按引用返回傳值呢?不就可以對返回值進行修改了嗎?

確實是這樣,但是這種返回傳值的方式比較少見,多見的還是常量引用傳值。

3. 按引用返回

按引用返回的使用場景確實比較少,多見於呼叫者需要對於返回物件的內部的資料進行修改。

template <typename Object>
class martix
{
public:
	matrix(vector<vector<Object>> v) : array{v}
		{}
	const vector<Object> & operator[](int row) const
		{ return array[row]; }
	vector<Object> & operator[](int row)
		{ return array[row]; }
private:
	vector<vector<Object>> array;
};

上述程式碼中,定義了一個二維陣列 matrix 類,其元素型別為 Object。同時定義了按常量引用返回的 operator= 操作符和按常量引用返回的 operator= 操作符。這樣是為了方便取值的只讀語義和修改值的修改語義(accessor or mutator)。

正因為我們這麼定義了,所以我們就可以寫出下列的程式碼:

void copy(const matrix<int> & from, matrix<int> & to)
{
	for (int i = 0, i < to.numrows(); ++i)
		to[i] = from[i];
}

乍一看這個函式的定義非常好,from 是被拷貝的值以按常量引用傳入,to 是需要修改的值以引用傳入。為了使得這個 to[i] = from[i] 等式編譯通過,我們必須定義兩個版本的 operator= 操作符。

左側的 to[i] 就是按引用返回的 operator 操作符,右側的 from[i] 則是按照常量引用返回的 operator 操作符。

這樣達到了語義上的統一。

四、實際開發中如何選擇

那麼詳細探討了這三種返回傳值的方式,我們在開發中該如何選擇呢?

這裡我還是畫了一個圖來闡述:

1

注:或許會有人問,按值返回有沒有常量的概念,答案是當然沒有。因為按值返回返回的是值的拷貝,你加個常量概念也無法達到對原值的 const 約束。也就是說,按值返回絕對是沒有 const 修飾的,因為毫無意義:)

五、總結

C++ 的程式碼很難看懂,也許就在這些方面,一方面入參的四種方式,一方面出值的三種方式。相互組合,就會讓人覺得晦澀難懂。

其實好好深入到裡面弄清楚其裡面的含義,也就比較好理解了。

這篇和上一篇寫傳參四種方式的部落格寫的非常認真,也希望能夠對 C++ 初學者能有一些幫助 _

To be Stronger:)