C++ 教學系列 ── Function 與 Memory

C++ 教學系列 ── Function 與 Memory

點此回到礦坑系列首頁:首頁

函式 (Function)

函式是一個有名稱的程式碼區塊,通常拿來表示一個過程,可以接受參數並產生一個結果,我們可以透過呼叫(calling) 來使用函式。

Function Declaration

Function declarations 可以出現在任何的 scope 內,Function 的型態由回傳型態與 Function 的 Declarator 組成。

n4868(9.3.4.6):… the type of the declarator-id in D is “derived-declarator-type-list noexcept function of parameter-type-list cv-qualifier-seq ref-qualifier returning T” …

Function Declaration 的語法長這樣:

T noptr-declarator ( parameter-list ) cv(opt) ref(opt) except(opt) attr(opt) (1)
auto noptr-declarator ( parameter-list ) cv(opt) ref(opt) except(opt) attr(opt) -> trailing (2)

T 為回傳的型別,第一個是一般的 Function 宣告形式,第二個是 C++11 後出現的尾端回傳形式。

  • noptr-declarator
    跟 array 一樣,可以擺任何合法的 declarator,但如果由 *&&& 開始,那麼需要用括號括起來,這會用在 Function 回傳陣列的指標(pointer of array) 時。

  • 參數列 (parameter-list)
    參數列可以是空的,每個參數間會用逗號來隔開。

看起來會長:

1
2
3
4
int fn1(int par1, int par2);    // (1)  
auto fn2() -> int; // (2)

int (*fn3())[3]; // function return pointer of array, which dimension is 3.

上面宣告了三個函式,第一個是正常的形式,第二個是有尾端回傳(trailing type) 的形式,如果要使用尾端回傳,於前方 T 的部分需要填上 auto,第三個是一個回傳型態為 int(*)[3] 的函式。

就像之前講變數一樣,我們要使用函式的話也需要先宣告它,函式的宣告可以有很多個,但定義只能有一個,我們又稱函式的宣告為函式原型(function prototype)。

函式的宣告可以省略參數的名字,也就是說這樣是可以的:

1
int fn1(int, int);    // (1)  

另外我們常把函式的宣告稱為函式的介面(interface)

額外閱讀:C++ - Function declarations inside function scopes?
額外閱讀:In the standard, what is “derived-declarator-type”?
額外閱讀:Why does C++ allow unnamed function parameters?

Function Definition

前面講完宣告了,跟變數不一樣,函式的定義需要另外寫,函式的定義會把函式的名字、型態與 function body 連結起來。函式的定義不能出現在函式裡面

在宣告函式時我們需要寫回傳值的型態,如果沒有要回傳東西,那就使用 void,如果有定義宣告回傳值型態但沒有回傳東西,那麼編譯器會報錯。另外 Function 的回傳型態不能是 Array type 或 Function type。

function body 由 compound statement 組成,因此需要用 {} 包起來,因此於 function 內宣告的變數生命週期與 function 一樣,每次 function 被呼叫時會去執行這個 compound statement,舉個例子:

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>  

void fn()
{
std::cout << "get in fn()\n";
}

int main()
{
fn();
}

上面這個例子中有一個函式 fn,參數列是空的,也沒有回傳物件,function body 裡面只有一行字串的輸出。在 main function 裡面我透過 () 呼叫了 fn,因此這段程式的執行結果會是「get in fn()」。

return statement

return 是一個 statement,它會結束目前正在執行的程式,回到剛剛呼叫函式的地方,return 的語法如下:

return expression(opt) ; (1)
return braced-init-list ; (2)

第一種會去計算我們寫的 expression,並將其結果回傳,如果沒有寫 expression,那麼此 statement 只能出現在回傳型態為 void 的函式中。

舉個例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>  

void fn(int i)
{
switch (i) {
case 1:
std::cout << "case 1\n";
return;
case 2:
std::cout << "case 2\n";
return;
default:
std::cout << "default\n";
return;
}

std::cout << "end of the function\n";
}

int main()
{
fn(1);
fn(2);
}

這個例子中 fn 內有一個 switch statement,其中我用 return 代替了 break,而 fn 的最後有一個字串的輸出,但這個輸出不會被跑到,因為在 switch 中執行到了 return,此時 fn 就結束了。

而第二種則是會使用 copy list initialization 去建構要回傳的物件並回傳,除非串列為空,若串列為空則是使用 value initialize,舉個例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>  

int fn(int n)
{
switch (n) {
case 0:
return {};
default:
return { n };
}
}

int main()
{
int i1 = fn(0); // 相當於 i1 = {}
int i2 = fn(1); // 相當於 i2 = {n}
}

注意版本要在 C++11 以上

Don’t return pointer of local variable

在回傳物件時有一點要注意的是別回傳指向區域變數的指標,因為在離開 function 時區域變數就離開了他們的 scope,因此其生命週期就結束了,此時的 pointer 為 dangling pointer。

舉個例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>  

int *fn()
{
int i = 2;
int *p = &i;

return p; // return 後 i 會被解構
}

int main()
{
int *p = fn(); // 此時 p 為 dangling pointer

std::cout << *p; // maybe caused an error
}

reference 也同理。

記憶體配置過程

一般的 C/C++ 程式在執行時記憶體的配置會長的像下圖那樣,主要可分為 text、data、bss、stack、heap 與 system 這幾個部分。

source:C 語言程式的記憶體配置概念教學

  • text 段
    text 也被稱為 code 段,存放可執行的 cpu 指令,這裡的資料是可以共用的,並且是唯讀的。

  • data 段
    data 段拿來放有初始化的靜態變數,如全域變數與 static 變數,舉個例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    #include <iostream>  

    int i = 0;

    void fn()
    {
    static int si = 0;
    }

    int main()
    {
    }

    其中的 isi 會存在 data 段。

    data 段還有分可讀寫與唯讀的部分,可讀寫的部分稱為 read-write area,拿來存放一般變數,而唯讀區則稱為 read-only area,負責存放固定的常數。

  • bss 段
    bss 段拿來存放沒有初始化的靜態變數,這些變數在程式執行前會被系統初始化為 0null,舉個例子:

    1
    2
    3
    4
    5
    int i;  

    int main()
    {
    }

    其中的 i 會被儲存在 bss 段。

  • stack 段
    stack 段拿來儲存函數的區域變數,以及各種函數呼叫時需要儲存的資訊,每一次函數呼叫時就會在 stack 段建立一個 frame,一層一層疊下去,這樣不同函數就不會互相干擾。

  • heap 段
    heap 段拿來儲存動態配置的變數,如 mallocnew 所建立出來的變數。

  • system
    system 段拿來儲存跟系統有關的環境變數與命令列參數。

source:C 語言程式的記憶體配置概念教學

舉個例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>  

int fn(int i)
{
int n = i;
std::cout << "in fn()\n";
return n;
}

int main()
{
int i1 = fn(1);
int i2;
}

程式執行的流程會長:

一開始先分配好 main function 需要的大小,然後將 fn(1) 的 frame 推到 stack 內,之後因為要回傳值,所以會將要回傳的值儲存到暫存器或 stack 中,再把 fn(1) 的 frame 刪除,並把剛剛儲存的值拿去給 i1 初始化。

new 與 delete

new expression

因為 fn 結束後其相對應的 stack frame 也會消失,因此區域變數也會跟著解構,如果想要自己掌控變數的生命週期,我們就需要利用動態配置,因為動態配置的變數會存在 heap 段,所以當 stack frame 消失時並不會影響到動態配置的變數。

要動態配置記憶體,需要利用 new expression,語法長這樣:

::(opt) new ( type ) initializer(opt) (1)
::(opt) new new-type initializer(opt) (2)
::(opt) new (placement-params) ( type ) initializer(opt) (3)
::(opt) new (placement-params) new-type initializer(opt) (4)

new 會試著在 heap 段建構相對應型態的變數,(1) 與 (2) 的差別在於 (2) 的型態沒有包含括號,舉個例子:

1
2
new int(*[10])();    // error: parsed as (new int) (*[10]) ()  
new (int (*[10])()); // okay: allocates an array of 10 pointers to functions

這邊型態是 function pointer int(*[10])(),所以有 () 在裡面,導致需要使用第一種。

另外,new-type 會把所有可以屬於 declarator 的 token 都包含近來:

1
2
new int + 1; // okay: parsed as (new int) + 1, increments a pointer returned by new int  
new int * 1; // error: parsed as (new int*) (1)

利用 new 所建立的物件是沒有名字的,new 在成功建構物件後會回傳它的記憶體位址,因此我們需要使用指標去儲存這個位址,不然之後會沒法使用這個物件。

new expression 代表的東西有兩種,一為 operator new,另一種為 operator new[],type 為 array type 時使用的為後者,其他時候使用的 new 為前者。

initializer 如果沒寫,那會使用 default initialized,我們通常會使用 () 來當初始化器,此時會透過 Direct initialized 來初始化 heap 上的物件,舉個例子:

1
2
3
4
5
6
7
#include <iostream>  

int main()
{
int *p = new int(20);
std::cout << *p; // 20
}

上面這裡用 new 在 heap 段建構了一個整數物件,初始化為 20,並將其位址回傳給 p,圖看起來會長這樣:


而如果物件的型態為 array type,我們通常會用空括號 `()` 來進行 value initialized,在 C\+\+11 後,則可以使用 `{}` 來初始化物件,舉個例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>  

int main()
{
int *p1 = new int[3]; // default initialized
// 亂數
for (int i = 0; i < 3; ++i)
std::cout << p1[i] << " ";
std::cout << '\n';

int *p2 = new int[3](); // value initialized
// 0 0 0
for (int i = 0; i < 3; ++i)
std::cout << p2[i] << " ";
std::cout << '\n';

// after C++11
int *p3 = new int[3]{ 1, 2, 3 }; // aggregated initialized
// 1 2 3
for (int i = 0; i < 3; ++i)
std::cout << p3[i] << " ";
std::cout << '\n';
}

上面有三個 new 出來的陣列,第一個沒有初始化器,因此會是預設初始化,第二個的初始化器則為 (),使用 value initialized,因此陣列的元素全被初始化為 0,第三個的初始化器則為 {},為每個元素都寫好了初始化的值。

delete

由於動態配置的物件我們需要自己掌握其生命週期,因此解構也需要自己來,使用的是 delete expression,語法長這樣:

::(opt) delete expression (1)
::(opt) delete [] expression (2)

第一個給一般物件用,第二個給 array type 的物件用。

expression 的型態需要是一個 pointer to object type,或是可以轉型為相對應的 pointer 的 class type,而值可以是 nullnew 出來的物件的位址。

基本上如果是單獨一個物件就用第一個,如果是連續空間就用第二個,舉個例子:

1
2
3
4
5
6
7
8
9
10
int main()  
{
int *p1 = new int;
delete p1;
p1 = nullptr;

int *p2 = new int[3];
delete[] p2;
p2 = nullptr;
}

一開始我們 new 了一個整數物件出來,之後將其解構,並將 p1 指標歸位,p2 同理。

new 物件出來就需要有 delete 將其解構,否則那個物件會一直存在 heap 區,導致 memory leak

動態配置陣列

之前有說過陣列的大小需要是靜態時期就能夠知道大小的常數,除非是動態配置,由此我們可知動態配置出來的陣列可以在執行期才決定大小,舉個例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>  

int main()
{
int size = 0;

std::cout << "輸入長度: ";
std::cin >> size;
int *arr = new int[size]{ 0 };

for (int i = 0; i < size; i++)
arr[i] = i;

std::cout << "元素值: " << '\n';
for (int i = 0; i < size; i++) {
std::cout << "arr[" << i << "] = " << arr[i]
<< '\n';
}

delete[] arr;

return 0;
}

可以看見 arr 陣列的大小為 size,是一個執行期才知道的數,再來再舉一個二維陣列的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>  

int main()
{
int size1, size2;
int **arr = new int *[2];

std::cout << "input size1: ";
std::cin >> size1;
arr[0] = new int[size1]{ 0 };

std::cout << "input size2: ";
std::cin >> size2;
arr[1] = new int[size2]{ 0 };

/*---------------輸出---------------*/
for (int i = 0; i < size1; i++) {
std::cout << arr[0][i] << " ";
}
std::cout << '\n';

for (int i = 0; i < size2; i++) {
std::cout << arr[1][i] << " ";
}
std::cout << '\n';
/*--------------------------------*/

// 清除陣列
for (int i = 0; i < 2; i++) {
delete[] arr[i];
}
delete[] arr;

return 0;
}

例子 source:new 與 delete

Parameter List

Parameter List 還有一些地方還沒講,但在講之前,大家需要先知道什麼是 reference,接下來的 call by value、call by reference 才聽得懂。

前面一直都有提到 reference 這個東西,reference 的中文叫「別名」或「參考」,通常參考當動詞用,別名當名詞用。在 int &a = b 這個語句中,我們會說 a 參考 b,或 a 引用了 b,因此 ab 的別名,或 ab 的參考,講法很多,自己溝通時聽得懂就好。

reference 是一個很重要的東西,在近代 C++ 中大量的被使用,算是近代 C++ 的一大基石。

以下內容我直接從 C++ Miner 的 Value Categories 篇擷取過來,做了一點小刪減,讓新手比較好看懂。

Reference

什麼是參考 (Reference) ?

參考是一種變數,其型別(Type) 是他連結到的東西的型態的引用(reference to type),這邊不講物件的原因是因為他連結到的東西不一定是個物件,也有可能是函式之類的東西,語法長這樣:

T & attr(opt) declarator (1)
T && attr(opt) declarator (2) (since C++11)

第一個為 Lvalue Reference,第二個為 Rvalue Reference,後面會介紹兩者的差異。

Reference 會像是被連結到的東西的別名一樣,呼叫、使用他,就等於呼叫、使用被連結到的東西。舉個例子:

1
2
3
4
5
int main  
{
int i = 0;
int &r = i;
}

i 是一個物件,而我們最一開始有舉過一個例子說物件就像一個箱子,名字就是箱子上的標籤,而 reference 又像被連結到的東西的別名,換句話說 reference 就是另外一張標籤:


直接操作 r 就等同於對物件 i 操作,所以才會說 ri 的別名。

參考並不一定會有 memory allocate,而且參考也不是一個 object,這樣才符合他的精神「別名」。儘管為了實現語意,Compiler 可能會給他 memory allocate。

參考一定要被初始化,來看下面這個例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int g( int ) noexcept;    //回傳 int 的函式  

void f() {
int i;
int &r = i; // r 與 i 連結
r = 1; // r 連結到的變數的值變為 1,也就是說 i 變為 1
int *p = &r; // p 為 i 的指標
int &rr = r; // rr 連結到 r 連結的變數,也就是說 rr 連結到 i
int ( &rg )( int ) = g; // rg 是函式 g 的參考,
rg( i ); // 呼叫 g 函式

int a[3];
int( &ra )[3] = a; // ra 連結到 a,ra 是 a 陣列的參考
ra[1] = i; // 改變 a[1] 的值
}

一但 reference 被初始化,我們就無法再對 reference 本身進行操作了。我們也不能有參考的參考(reference to reference),參考的陣列(arrays of reference),和參考的指標(pointer to references),因為 reference 並不是物件(object)。

n4868(9.3.4.3):There shall be no references to references, no arrays of references, and no pointers to references.

這樣看起來,一旦參考被初始化後,參考「本身」好像就消失在程式當中,真的變成一個「別名」了,那麼底層是如何實作的呢? 不一定! 通常是利用指標來實作,但要記得,他不一定要是指標,參考與指標本質上是不同的東西,僅僅是因為他們倆個的性質很相似而已。

那麼,Compiler 知道參考本身的存在嗎? 答案是知道:

1
2
3
4
5
int a = 0;  
int &b = a;

std::cout << std::boolalpha
<< std::is_same_v<decltype(a), decltype(b)>; // false

可以看見 Compiler 可以分出來兩者的差別,因為兩者的 type 是不同的。那麼我們來看看一個有編譯器優化的狀況:


連結在這裡,記得要把編譯器優化打開。 我們可以看見 std::cin >> r; 像是被替換成 std::cin >> a; 了,跟 inline 類似。 (感謝Cy大神補充)

如果對 Referecne 的定義與相關的詳細規範還有興趣,可以到這裡看看。

Lvalue Reference

那麼現在大家都有背景知識了,就來看一下什麼是 Lvalue Referecne 和 Rvalue Referecne 吧。

首先我們看 Lvalue Reference,語法長這樣 (來源)

& attr(opt) declarator

lvalue 裡面的 「l」 主要的意涵是 locatable,也就是有固定位址的,因此 Lvalue Reference 會與一個有固定位址的物件連結,int &r = a; 這樣的話 r 就是一個 Lvalue Referecne,型態是 int&。那麼他有幾個用處:

  • Lvalue Reference 可以用來連結一個已經存在的 object,也就是 Lvalue Expression 回傳的物件:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    #include <iostream>  
    #include <string>

    int main() {
    std::string s = "Ex";
    std::string &r1 = s;
    const std::string &r2 = s;

    r1 += "ample"; // 改動了 s
    // r2 += "!"; // error: cannot modify through reference to const (不能更改一個 const string 的 reference)
    std::cout << r2 << '\n'; // Example
    }
  • 可以用來當作 pass-by-reference 的函式參數:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    #include <iostream>  
    #include <string>

    void double_string(std::string &s) {
    s += s; // 's' 和 main() 裡面的 `str` 是同一個物件
    }

    int main() {
    std::string str = "Test";
    double_string(str);
    std::cout << str << '\n';
    }
  • 可以用來當作函式回傳的物件,如此一來函式回傳的型態會是 int&

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    #include <iostream>  
    #include <string>

    char& char_number(std::string &s, std::size_t n) {
    return s.at(n); // string::at() 回傳一個reference,型態是 char&
    }

    int main() {
    std::string str = "Test";
    char_number(str, 1) = 'a';
    std::cout << str << '\n'; // Tast
    }

這些差不多就是 Lvalue Reference 的作用了,應該非常符合他是一個「別名」的意義吧!

Rvalue Reference

先談談歷史

在進到 Rvalue Reference 前,我們要先稍微了解一下歷史,才會知道為何要有 Rvalue Reference,所以我們先從舊的 C++ 講起。

在以前,我們只擁有 Lvalue Reference,但 Lvalue Reference 沒有辦法連結到沒有 cv 限定詞的暫時物件或常量:

1
2
3
4
5
int main() {  
int &r = 0; //error: cannot bind non-const lvalue reference of type 'int&' to an rvalue of type 'int'

return 0;
}

因為 Lvalue Reference 只能綁定到一個 Lvalue Expression 回傳的物件,從語意上來講,更改暫時物件的值不一定是合理的,而常量值更不用說了,他甚至可能不是個物件,沒有儲存位址,要怎麼改動他呢? 由於 Lvalue Refference 綁定到的物件基本上可以被更改(除非他有加上 const 限定詞),所以我們無法綁定 Prvalue Expression 與 Xvalue Expression (統稱 Rvalue)。

那麼這會出現什麼問題呢? 最明顯的問題出在函式 call-by-reference 的身上:

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>  

int func(int &a, int &b) {
return a + b;
}

int main() {
int a = 1, b = 1;
int c = func(a, b); // ok

int d = func(1, 1); //error: cannot bind non-const lvalue reference of type 'int&' to an rvalue of type 'int'
}

可以看見 func(a, b) 是合法的,這很合理,我們傳了兩個有固定位址的物件進去,但是
func(1, 1) 就有問題了,因為這兩個是數字,根本沒有在記憶體佔有空間,而我們的需求也很直覺,就是將傳入的兩個整數相加後回傳。

怎麼解決呢? 照剛剛的邏輯,不能利用 Lvalue Reference 做連結的原因是因為常量值照理說不應該被更改。那麼只要我們保證不更改他就好了對吧! 所以答案是加上 const

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>  

int func(const int &a, const int &b) {
return a + b;
}

int main() {
int a = 1, b = 1;
int c = func(a, b); // ok

int d = func(1, 1); // ok
}

我們先在這邊暫停一下,剛剛不是說了常量值可能沒有記憶體位址? 那麼就算加上了 const 也不該可行呀! 所以這時候出現了一個東西叫做臨時物化(TMC),我們來看看利用 const int& 型態的 reference 綁定 1 時到底發生了什麼:

1
2
3
int main() {  
const int &cr = 1;
}

組語輸出:

1
2
3
4
5
6
7
8
9
10
main:  
push rbp
mov rbp, rsp
mov eax, 1
mov DWORD PTR [rbp-12], eax
lea rax, [rbp-12]
mov QWORD PTR [rbp-8], rax
mov eax, 0
pop rbp
ret

不會組語的朋友沒關係,我這邊寫個偽代碼來示意:

1
2
3
4
int main() {  
int __e = 1; //mov DWORD PTR [rbp-12], eax
const int &cr = __e; //mov QWORD PTR [rbp-8], rax
}

發現了嗎? 產生了一個新的變數! 其實他是個匿名物件,但為了方便我把它取名叫 __e,如此一來我們的確無法透過 cr 來更改到 1(__e) 的值,卻又連結到了 1 這個常量,就好像替 1 取了一個別名叫做 cr

但這樣又出現了兩個問題,首先是我們無法區分 Rvalue 與 Lvalue,剛剛的例子中可以看見 1ab 都一樣可以傳進 func() 裡面。

再來是既然產生了臨時物件,我們也透過 Reference 連結到它了,而且它本身也不是個不可更改的物件,因為在被 Reference 連結後,它的生命週期已經大幅增加,不再是個暫時物件了,那麼也就是說:

1
2
3
4
5
int main() {  
int __e = 1;
const int &cr = __e;
cr; // cr 是 Lvalue Expression
}

cr 已經是一個 Lvalue Expression 了,既然我們都知道會有臨時物件出現,並且它的生命週期會因為 Reference 的連結而增加,那麼可以被更改也很合理吧? 但是在函式中很明顯它不這麼覺得:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>  

int func(const int &a, const int &b) {
++a; //error: increment of read-only reference 'a'
return a + b;
}

int main() {
int a = 1, b = 1;
int c = func(a, b); // ok

int d = func(1, 1); // ok
}

++a 被擋了下來,這也很正常,因為它是唯讀的,那麼要怎麼做才可以讓它像這樣呢:

1
2
3
4
int main() {  
int __e = 1;
int &cr = __e;
}

如果能夠沒有 const,那麼 ++a 就可以過,事情就解決了對吧?

但這樣就回到了一開始的問題,常量並沒有記憶體位址,語意上來說不該能被更改,我們會說它能更改是從臨時物化與記憶體的角度去看,但在寫程式的時候我們應該由語意方面來寫,而不是底層,因為還有 Compiler 優化與平台差異的問題,底層應該交給他們去判斷。 於是陷入了死胡同,直到 C++11 時為了滿足移動語意,Rvalue Reference 的出現及值類別、TMC、Copy Elision 等更詳細的定義,這個問題才被解決。

Rvalue Reference 的出現

所以為了滿足移動語意,Rvalue Reference 出現了,大量取代了以前使用 const Reference 的情景。 Rvalue Reference 是 C++11 後的東西,使用前記得看一下自己的 C++ 版本,語法長這樣:

&& attr(opt) declarator

沒錯,就只是多了一個 &,意義就大有不同,注意它不是參考的參考,前面已經說過不能有參考的參考了。

Rvalue Reference 有幾種特性 (來源)

  • 延長暫時物件的生命週期:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    #include <iostream>  
    #include <string>

    int main() {
    std::string s1 = "Test";
    // std::string&& r1 = s1; // error: can't bind to lvalue

    const std::string& r2 = s1 + s1; // okay: 利用唯讀的 Lvalue Reference 來延長 s1 + s2 的生命週期
    // r2 += "Test"; // error: can't modify through reference to const

    std::string&& r3 = s1 + s1; // okay: 利用 Rvalue Reference 來延長 s1 + s2 的生命週期
    r3 += "Test"; // okay: 可以更改連結到的物件的值
    std::cout << r3 << '\n'; //TestTestTest
    }

    可以看見唯讀的 Lvalue Reference 跟 Rvalue Reference 都可以延長暫時物件的生命週期,但只有 Rvalue Reference 可以更改連結到的物件的值,因為其不是唯讀的型態。

  • 函式 call-by-reference 時能夠區分 Rvalue 與 Lvalue:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    #include <iostream>  
    #include <utility>

    void f(int& x) {
    std::cout << "lvalue reference overload f(" << x << ")\n";
    }

    void f(const int& x) {
    std::cout << "lvalue reference to const overload f(" << x << ")\n";
    }

    void f(int&& x) {
    std::cout << "rvalue reference overload f(" << x << ")\n";
    }

    int main() {
    int i = 1;
    const int ci = 2;
    f(i); // calls f(int&)
    f(ci); // calls f(const int&)
    f(3); // calls f(int&&)
    // would call f(const int&) if f(int&&) overload wasn't provided
    f(std::move(i)); // calls f(int&&)

    // rvalue reference variables are lvalues when used in expressions
    int&& x = 1;
    f(x); // calls f(int& x)
    f(std::move(x)); // calls f(int&& x)
    }

    可以看見 Prvalue Expression 與 Xvalue Expression 會傳入 Rvalue Reference 的版本,與 Lvalue Reference 的版本區分開來了。

    這是我們能夠實現移動語意的關鍵,因為移動建構子或其他接受移動的函式吃的就是 Xvalue,建構子需要能夠區分 Lvalue 與 Rvalue。

Parameter Passing

Function 中傳遞參數的方式有兩種,一為 pass by value、另一種為 pass by reference。

Pass by value

前面我們已經知道了在 call function 時記憶體內會建立 stack frame,參數會被複製進去,舉個例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>  

void fn(int a, int b)
{
a = 5;
b = 10;
}

int main()
{
int i = 0, j = 0;
fn(i, j);

std::cout << i << " " << j;
}

上例中有一個函式 fn,吃兩個參數 ab,並在 function body 中更改了他們的值。 而 main function 中有兩個整數物件 ij,都初始化為 0,並傳進 fn 中。

fn 呼叫完畢後我輸出了 ij 的值,因為參數傳遞的方式是 pass by value 的關係,所以 fn 裡面的修改並不會影響到 main function 內的 ij

圖解起來長這樣:

整體步驟大概是:

  1. 進入 main function,配置好 ij 的記憶體空間並初始化
  2. 呼叫 fn,將參數存入暫存器或 stack
  3. 建立起 fn 的 stack frame,並將 ab 透過暫存器的值初始化
  4. function body,執行 a = 5;b = 10;,更改 ab 的值
  5. 退出 function,解構 ab,並回收 stack frame

Pass by reference

上面那個方法將會複製傳進去的參數,這有一個壞處,當傳進去的物件很大時,將其複製就會很花時間,或是有些物件根本就不允許複製,此時就可以使用 reference 來傳遞參數,如此一來就可以避免整個物件的複製。

另外一點就是當我們想要更改原物件時,由於傳遞進去的是一個複本,因此在 function 內做的更改並不會去修改到原先的物件。

聽起來 pass by reference 的好處很多,你可能會想說那總是使用 reference 來傳遞就好,但其實對於小物件,編譯器與底層硬體會有針對 pass by value 的優化,所以像是 int 這種小物件,用 value 來傳並不一定會比較慢,甚至會比較快。

pass by reference 比較快的結果只有在一種情況下會發生,那就是接受參數的 function 內沒有發生 parameter optimization 導致 function local 必須配置記憶體給 parameter。且 pass by value 有時還有 cache-inline 的優勢。

如果單純只考量速度,通常來說只要 value size < pointer size(通常是 64 bits),那我們就會使用 pass by value,然而實際上在寫 code 的時候還要考慮維護性,此時可能會使用 const reference,這部分就要請大家自己拿捏判斷了。

講了這麼多,那就開始看要怎麼使用 reference parameter 吧!

只需要直接在參數列使用 reference type 就好,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>  

void fn(int& a, int& b)
{
a = 5;
b = 10;
}

int main()
{
int i = 0, j = 0;
fn(i, j);

std::cout << i << " " << j;
}

執行結果可以看見 ij 的值在經過 fn 後都改變了,因為我們傳的不是利用 copy,而是利用 reference 來傳遞參數,我們可以利用 pointer type 的參數做到類似的事情:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>  

void fn(int *a, int *b)
{
*a = 5;
*b = 10;
}

int main()
{
int i = 0, j = 0;
fn(&i, &j);

std::cout << i << " " << j;
}

可以看見 ij 的值一樣也改變了,但要注意 pointer 是個物件,而 reference 不是,所以我們能利用 pointer 改變原物件的原因是因為我們複製了原物件的位址進去,並利用位址來操作原物件,而 reference 則是直接將參數與原物件連結,以概念上來說是不一樣的,reference 的方法中,等同於直接操作原物件,但 pointer 的方法則是間接操作原物件。

額外閱讀:Want Speed? Pass by Value.
額外閱讀:Pass by value faster than pass by reference
額外閱讀:WANT SPEED? DON’T (ALWAYS) PASS BY VALUE.

預設引數 Default Argument

有些函式可能有某個參數在大部分的情況下都是固定的值,例如一個生成視窗的函式,一個視窗預設大小為 250 $\times$ 250,此時就可以利用 Default argument 來實作:

1
2
3
4
5
6
Window make_window(int length = 250, int width = 250) {  
Window window(length, width); // 生成視窗
//...

return window;
}

預設的參數是由左至右來解讀的,也就是說呼叫時長這樣:

1
2
3
4
5
int main() {  
Window A = make_window(); // length = 250, width = 250
Window B = make_window(100); // length = 100, width = 250
Window C = make_window(100, 150) // length =
}

也因為是由左至右解讀的,所以以下這個形式的 Default argument 是錯的:

1
Window make_window(int length = 250, int width);    // error:default argument missing for parameter 2 of 'Window make_window(int, int)'  

因為是由左至右解讀的,因次在呼叫時若寫 make_window(100),這個 100 是會傳給 length 而不是 width,導致 width 沒有收到值也沒有預設值,所以上面那個形式是錯的。

我們可以透過多個有預設引數的宣告來解決這個問題:

1
2
Window make_window(int length, int width = 50);  
Window make_window(int length = 250, int width);

這樣在解讀時就不會有漏掉值的問題了。

函式重載 Function Overloading

當有很多個 Function 可能有類似的操作時,或是一個 Function 有很多種操作時,如 print 函式可能可以輸出字串、整數、布林值,此時我們可以透過 Function Overload 來幫助我們實作。

Function Overload 的中文翻譯很多,有重載、多載、過載等等,建議記英文比較好,因為還有兩個東西叫 Override 和 Overwrite,中文名稱也很像,之後講 Class 時會提到。

那我們就來看看怎麼使用 Function Overloading 吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>  
#include <string>

void Print(int i) {
std::cout << i << '\n';
}

void Print(std::string str) {
std::cout << str << '\n';
}

void Print(bool flag) {
std::cout << flag << '\n';
}

int main() {
Print(123);
Print(std::string("Hello world"));
Print(true);
}

上面有三個同樣名稱但參數不同的函式,此時編譯器就會根據你傳入的參數去找應該要呼叫哪一個 function,這個動作叫做函式匹配(Function matching)。

在 Function matching 中,他會先去查名字(Name lookup),如果有同名的 Function,才會去對照 parameter list,這個叫做 ADL(Argument-dependent lookup)。

Top level const、Low level const

考慮這個例子:

1
2
3
4
5
6
7
8
void Print(int i) {  
std::cout << "without const\n";
}

// error: redefinition of 'void Print(int)'
void Print(const int i) {
std::cout << "with const\n";
}

你會發現這個編譯器報了錯,很顯然的下面那個 Function 參數列中的 const 並無法幫助 ADL 分辨 Function,這個概念牽扯到 const 的 level。

const 是有分頂層(top-level) 與底層(low-level) 的,廣義來說,頂層的 const 代表 const 作用在物件本身,如 const int iint *const i_ptr 等等,物件本身被賦值後就無法再做修改;底層的 const 代表 const 作用在型態上,這會出現在複合型別內,如 const int *ptrconst int &r 等等,這個 const 表示物件連結到的物件的型態有 const 修飾。

而回到上面的例子,編譯器會報錯的原因很單純,因為 ADL 無法區分頂層的 const,原因是 parameter 中頂層的 const 並不會算在 function type 的裡面,而 ADL 是根據 function type 來查找的,因此其便會判斷上下兩個 Print 是同一個 Function,因此有兩個定義而報錯。

但 low-level 的 const 是沒有問題的:

1
2
3
4
5
6
7
void Print(int *i) {  
std::cout << "without const\n";
}

void Print(const int *i) {
std::cout << "with const\n";
} // okay, passed the compiled

遞迴 (Recursion)

在函式中是可以再呼叫自己的,可以是間接呼叫或是直接呼叫,這種呼叫自己的函式我們稱為遞迴函式,一個常見的例子是費氏數列的運算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>  

int fib(int n)
{
if (n == 1 || n == 2)
return 1;

if (n > 2)
return fib(n - 1) + fib(n - 2);

return 0;
}

int main()
{
std::cout << fib(1) << " " << fib(2) << " " << fib(3) << " " << fib(4) << "...";
}

上例中 fin(1)fin(2) 會直接進到第一個 if statement,因此輸出為 1,之後的會進到第二個 if statement,因此會將 n - 1n-2 當參數再去呼叫一次 fib

我們可以把樹畫出來看,以 fib(4) 為例,會長的像這樣:

額外閱讀:Tail recursion in C++

Function Pointer

Function Pointer 與一般的指標差別在於 Function 並不是物件,因此特性有一些差別,Function Pointer 一樣會指向一個特別的型態,這個型態以 Function 的 return type 與 parameter list 組成,如一個指向 int(int, int) 的指標 int (*ptr)(int, int)

一個 function pointer 可以利用 non-member function 或 static member function 來初始化,在賦值時會 implicity 的加上 &

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>  

int fn()
{
return 0;
}

int main()
{
int (*ptr1)() = &fn;
int (*ptr2)() = fn; // implicit add & operator, equivalent to above one

int a = ptr1();
int b = ptr2();

std::cout << a << " " << b;
}

implicity 加上 & 的這個動作算一種 conversion,叫做 function-to-pointer conversion。

如果要使用 function pointer 指向的函式,可以直接使用 calling operator () 而不用使用 * operator,但也是可以 deference 之後再去呼叫,這樣會變成使用 function lvalue 來呼叫,通常來說外顯行為是一樣的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>  

int fn()
{
return 0;
}

int main()
{
int (*ptr1)() = &fn;
int (*ptr2)() = fn; // implicit add & operator, equivalent to above one

int a = (*ptr1)();
int b = (*ptr2)();

std::cout << a << " " << b;
}

Main function 中的 argc、argv 參數

我們可以透過命令列傳一些參數進去 main function,此時這些資訊會利用 argcargv 這兩個參數來儲存,argc 是個整數,記錄命令列參數的個數,而 argv 則是一個指向 char array 的 pointer,儲存命令列內容:

1
2
3
4
5
6
7
8
9
#include <iostream>  

int main(int argc, char *argv[])
{
std::cout << argc << " ";

for (int i = 0; i < argc; ++i)
std::cout << argv[i] << " ";
}

假設我們的執行檔名稱為 test.exe,那麼當我執行指令 ./test.exe 1 2 3 時,argc 就為 4,第一個參數為程式的名稱,第二三四個參數則為 1 2 3,因此上面的例子會輸出 4 C:\Mescpp\test\test.exe 1 2 3 (我這邊執行檔位置在 Mescpp\test 下)