【C++探索之旅】第一部分第八課:傳值引用,文件源頭

0?wx_fmt=jpeg


內容簡介

1、第一部分第八課傳值引用,文件源頭

2、第一部分第九課預告:數組威武,動靜合一



傳值引用,文件源頭


這一課的標題有點怪。其實是由這一課的幾個重點內容結合起來取的名,慢慢學習就知道啦。


上一課《【C++探索之旅】第一部分第七課:函數效應,分而治之》中,我們初步認識了函數。


不過不要高興得太早,你以爲函數就這樣離你遠去了嘛?怎麼可能,函數將伴隨一生好嗎,只要你繼續編程的話。哈哈,所以你是跑不掉了~


【小編,都跟你簽了協議了,沒吃藥不要隨便出來溜達】


這一課我們就繼續深入學習與函數相關的幾個知識點。不過函數我們會一直深入學習的,以後有了類,對象等面向對象的知識,到時函數還會換一個稱呼。



值傳遞和引用傳遞


值傳遞


我們首先來學習在函數範圍內操作系統是怎樣管理內存的。


再拿我們之前的addTwo函數舉例。這個函數很簡單,就是在參數的基礎上加上2,然後返回其值。如下:


int addTwo(int a)
{
    a += 2;
    return a;
}


是不是覺得 a+= 2; 這一句有點多餘呢?完全可以直接 return a + 2; 啊。接下來會知道這裏爲什麼添加這一句。


寫個小程序測試一下此函數:


#include <iostream>
using namespace std;

int addTwo(int a)
{
    a+=2;
    return a;
}

int main()
{
    int number(4), result;
    result = addTwo(number);
    
    cout << "number的值是 : " << number << endl;
    cout << "調用函數後結果是 : " << result << endl;
    return 0;
}


運行這個程序,輸出:


number的值是 : 4

調用函數後結果是 : 6


程序中最關鍵的一句當然就是


result = addTwo(number);


當addTwo函數被調用時,其實發生了很多事情:


  1. 程序獲取number的值,發現是4。

  2. 申請了內存中的一塊地址(好像一個抽屜),抽屜上的標籤是a(名字),類型是int,抽屜裏存放的值等於number的值,爲4。

  3. 程序進入到函數體中。

  4. 將變量a加上2,a變爲6。

  5. a的值被作爲函數的返回值賦給變量result。result的值也變爲6了。

  6. 跳出函數。


重要的一點是:變量number被拷貝到了內存的一個新的地址上(新的抽屜),這個新的抽屜的標籤是a。我們說參數a是通過值來傳遞的(number的值拷貝給了a),叫做值傳遞。當我們的程序在addTwo函數裏時,內存中的情況大致如下圖所示:


0?wx_fmt=jpeg

因此我們在內存中使用了三個"抽屜"。注意:number變量並沒有被改變。


所以在程序中操作變量a的時候,已經與number沒什麼關係了,只是操作number的一份值的拷貝。


引用傳遞


我們之前的課程初步介紹了引用(reference)的概念。其實引用這個名詞太抽象,一般第一次接觸引用的朋友都覺得:哇,好高深的感覺。其實一點也不高深,引用應該更確切地被稱爲"別名"。


比如小編叫謝恩銘,有的人可能會戲稱爲小銘。那小銘就是我的別名啦,這兩個名字是不是指同一個人呢?是,都是指向略微頑皮,但在編程技術上絕不馬虎的小編。


除了前面說的值傳遞的方式,也就是將變量number的值拷貝到變量a中。


除了值傳遞,我們也有其他的方式。可以給內存中名爲number的抽屜再貼一個標籤,叫做a。等於給number變量取了一個別名,稱爲a。此時函數的參數就要使用引用了。如下:


int addTwo(int& a)  // 注意 & 這個表示引用的符號
{
    a+=2;
    return a;
}


當我們調用函數時,就沒有之前那樣的值拷貝了。程序只是給了number變量一個別名而已。當我們的程序在addTwo函數裏時,內存中的情況大致如下圖所示:


0?wx_fmt=jpeg


這次,變量a和變量number是指向同一塊內存地址(同一個抽屜),抽屜裏存放的值是4,a和number只是這個抽屜的兩個不同標籤而已。我們說變量a是通過引用傳遞的,稱爲引用傳遞。


引用很特別,從上圖中我們看到,我們在內存中並沒有給a這個引用變量分配新的內存地址,它是指向它所引用的number這個變量的內存地址。所以引用變量和它所指向的變量的內存地址是一樣的。我們可以來測試一下:


#include <iostream>
using namespace std;

int main()
{
    int number(4);
    int &refNumber = number;
    cout << "number的內存地址是 : " << &number << endl;
    cout << "refNumber的內存地址是 : " << &refNumber << endl;
    return 0;
}


運行,輸出:


0?wx_fmt=jpeg


如上圖中所示,變量number和引用變量refNumber的內存地址是完全一樣的。


既然值傳遞可以幫我們解決問題,爲什麼要用引用傳遞呢?既生瑜何生亮呢?引用有什麼好處呢?


首先,從上例中我們知道了:引用不需要在內存中新開闢一塊地址,減少開銷。


C語言沒有引用的概念,但是C++有。引用傳遞可以讓我們的函數addTwo直接修改參數。繼續使用之前的測試程序,只不過這次在函數參數中的是一個引用:


#include <iostream>
using namespace std;

int addTwo(int &a)
{
    a+=2;
    return a;
}

int main()
{
    int number(4), result;
    result = addTwo(number);
    
    cout << "number的值是 : " << number << endl;
    cout << "調用函數後結果是 : " << result << endl;
    return 0;
}


運行這個程序,輸出:


number的值是 : 6

調用函數後結果是 : 6


爲什麼number的值變成了6呢?之前在值傳遞的例子中,number的值是不變的。


很簡單,我們之前說了,引用其實就是別名。a就是number的一個別名。那麼其實它們指向的是同一個內存地址,因此對a做加2操作,也就是對number做加2操作,當然會改變number的值。


因此,引用的使用要謹慎,因爲它會改變所引用的那個對象。


對於引用的使用,經典的例子也就是swap函數了,用於交換兩個參數的值。


#include <iostream>
using namespace std;

void swap(double& a, double& b)
{    
    double temporary(a);  // 將變量a的值保存到變量temporary中    
    a = b;           // 用b的值替換a的值    
    b = temporary;   // 將temporary的值賦給b,就是用a的舊值替換b的值
}

int main()
{    
    double a(2.3), b(5.4);    
    cout << "a的值是 " << a << "  b的值是 " << b << endl;    
    swap(a,b);   // 使用swap函數    
    cout << "a的值是 " << a << " b的值是 " << b << endl;    
    return 0;
}


運行程序,輸出:


a的值是 2.3 b的值是 5.4

a的值是 5.4 b的值是 2.3


可以看到a和b這兩個變量的值被交換了。


假如我們不用引用傳遞,而是用傳統的值傳遞,那麼我們交換的就只是變量的拷貝而已,並不是變量本身。


暫時,引用的概念對大家可能還是有些抽象,但是不要擔心,我們之後的課程會經常用到引用的,關於引用還有不少要說的呢,慢慢來,熟能就生巧了嘛。


之後,學到指針那章,還會講解引用和指針的異同。


不可改寫的引用傳遞


既然說到了引用,就需要介紹一個引用的慣常用法。這個用法在接下來的課程中會很有用,我們先來一窺堂奧。


我們說引用傳遞相比值傳遞有一個優勢:不進行任何拷貝。


設想一下,如果我有一個函數,其中一個參數是string類型的字符串。假如你的這個字符串變量裏面的內容是很長的一串字符串,例如有一本小書那麼多的字 符。那麼拷貝這麼長的一個字符串的開銷可是很大很費時的,即使拷貝是在內存中進行。這樣的拷貝完全沒什麼意義,因此我們就要避免使用值傳遞的方式。


當然,你會自豪地對我說:我們可以用引用傳遞啊。是的,好主意。使用引用傳遞的話,就不用拷貝了。但是,引用傳遞有一個小缺陷:可以修改所指向的對象。不過這也不能說是缺陷吧,畢竟正是因此引用也才顯得有用啊。


void f1(string text);  // 進行耗時的拷貝
{
}
void f2(string& text);  // 不進行拷貝,函數可以直接修改string變量的值
{
}


解決辦法就是使用:不可改寫的引用傳遞。


之前的課程我們介紹過const這個關鍵字,被它修飾的變量就變成不可改變的變量。


我們用引用來避免了拷貝,再用const變量修飾引用就可以使引用不能被修改了。


void f1(string const& text); // 不進行拷貝,函數也不能修改string變量的值
{
}


目前來說這個用法對我們貌似沒太大用處,但是本課程的第二部分會學習面向對象編程,到時會經常用到這個技術。



頭文件和源文件,合理安排


在上一課介紹函數時,我們已經說過函數是爲了可以重用(重複使用)已經創建的"磚塊"(代碼塊)。


目前,我們已經學習瞭如何創建自定義的函數,但是這些函數暫時還是和main函數位於同一個文件中。我們還沒有真正很好地重用它們。


和C語言一樣,C++也允許我們將程序分割成不同的源文件。在這些源文件中我們可以定義自己的函數。


在要用到這些函數時,引入文件的內容,也就引入了這些函數。這樣我們的函數就可以爲不同的項目所使用了。藉此我們就可以真正意義上地重用這些被分割開的磚塊來建造房屋了。


必要的文件


但是爲了更好地組織程序,計算機先驅們用了兩種文件,而不是一種文件(爲何"多此一舉",學下去就知道了):


  • 源文件(source file):以.cpp結尾(也可以由.cc,.cxx,.C結尾),包含函數的具體源代碼。

  • 頭文件(header file):以.h結尾(也可以由.hxx,.hpp結尾),包含函數的描述,術語稱爲函數的原型(prototype)。


這下知道爲什麼今天這課的標題裏有"文件源頭"了吧?就是指頭文件和源文件。


那麼我們還是以addTwo函數爲例,來分別創建源文件和頭文件吧。


int addTwo(int number)
{
    int value(number + 2);
    return value;
}


我們會用CodeBlocks這個IDE(集成開發環境)來演示。如果你是用文本編輯器(比如Vim,Emacs,Sublime,等)加gcc(編譯器)來編寫和編譯代碼的,那麼直接創建文件就好了。


而且,我們默認你的Codeblocks的C++項目已經創建好了,也就是說已經有了一個自帶main.cpp文件的項目,名稱隨便取。


如下圖:


0?wx_fmt=jpeg


我們只是演示如何創建函數的源文件和頭文件。


源文件


首先,我們按照以下順序打開菜單欄:File > New > File。


0?wx_fmt=jpeg


然後在以下窗口中選擇C/C++ source:


0?wx_fmt=jpeg


然後,點擊Go這個按鈕。進入下圖的窗口:


0?wx_fmt=jpeg


選擇C++,點擊Next按鈕。進入下圖所示窗口:


0?wx_fmt=jpeg


填寫要創建的源文件的完整路徑(選擇目錄,然後填寫文件名)。我們把文件的目錄選擇爲和main.cpp一樣的目錄。


文件名字儘量做到見名知意,不要來個 1.cpp,以後都不知道這文件幹什麼的。


我們的演示中使用了math.cpp,因爲我們的addTwo函數進行的是數學運算,就用math(mathematics的縮寫)來命名。


然後可以把Debug和Release都選上。


點擊Finish。你的源文件就創建好了。我們接着來創建頭文件。


頭文件


開頭和之前創建源文件是一樣的。依次打開菜單:File > New > File,然後在下圖窗口中選擇C/C++ header:


0?wx_fmt=jpeg


點擊Go進入下一步:


0?wx_fmt=jpeg


建議頭文件的名字和源文件的名字一樣,只是後綴名不同。


頭文件的也放在和main.cpp一樣的目錄。那個MATH_H_INCLUDED是自動生成的,不需要改動。


點擊Finish。你的頭文件就創建好了。


一旦源文件和頭文件都創建好之後,你的項目應該是類似下圖這樣的:


0?wx_fmt=jpeg


現在文件有了,我們就要往裏面填寫內容了,就是來定義我們的函數。


完成源文件


之前說過,源文件包含了函數的具體定義。這是一點。


另外還有一點理解起來略爲複雜:編譯器需要獲知源文件和頭文件之間存在聯繫。


目前,我們的源文件是空的,我們首先需要往裏面添加這樣的一行:


#include "math.h"


你應該對這行代碼的格式不陌生,我們之前說過。


#include <iostream>


這一行我們熟悉的代碼,是爲了引入C++的標準iostream(輸入輸出流)庫。


那麼,#include "math.h" 就是爲了引入math.h 這個文件中定義的內容。


但是,爲什麼C++標準庫的頭文件是包括在尖括號中,而我們自己定義的math.h是包括在雙引號中呢?


C++有一些編寫好的頭文件(比如標準函數庫等等),它們存放在系統的include文件夾裏。當我們使用#include <文件名> 命令時,編譯器就到這個文件夾裏去找對應的文件。


顯然,用這種寫法去包含一個我們自己編寫的頭文件(不在系統include文件夾裏)就會出錯了。


所以包含C++提供的頭文件時,應該使用尖括號。


相反地,#include "文件名" 命令則是先在當前文件所在的目錄搜索是否有符合的文件,如果沒有再到系統include文件夾裏去找對應的文件。


現在我們就來完成math.cpp的內容,其實很簡單,如下:


#include "math.h"

int addTwo(int number)
{
    int value(number + 2);
    return value;
}


完成頭文件


我們可以看到,math.h這個頭文件的初始內容不是空的。而是:


#ifndef MATH_H_INCLUDED
#define MATH_H_INCLUDED

#endif // MATH_H_INCLUDED

這幾句指令又是什麼意思呢?


它們是爲了防止編譯器多次引入這個頭文件。編譯器並不總是那麼智能的,有可能會循環引入同樣的頭文件。


前面說過,頭文件包含函數的原型。我們要把原型寫在上面第二句話和第三句話之間,如下所示:


#ifndef MATH_H_INCLUDED
#define MATH_H_INCLUDED

int addTwo(int number);

#endif // MATH_H_INCLUDED


上面的三個以#開頭的語句是如何防止重複引入頭文件內容的呢?


這幾句語句被稱爲條件編譯語句。


首先,如果是第一次引入頭文件,那麼,就會讀到第一句命令:


#ifndef MATH_H_INCLUDED


ifndef是if not defined的縮寫,表示"如果沒有定義"。整句的意思就是:假如MATH_H_INCLUDED沒有被定義。


那如果是第一次引入此頭文件,MATH_H_INCLUDED確實還沒被定義。#ifndef MATH_H_INCLUDED 成立。


所以就進入此條件編譯語句中,執行 #define MATH_H_INCLUDED,定義(define是英語"定義"的意思)MATH_H_INCLUDED


然後引入


int addTwo(int number);


最後,來到第三句條件編譯指令:


#endif // MATH_H_INCLUDED


endif是end if的縮寫,表示"結束if條件編譯",後面的 // MATH_H_INCLUDED 是註釋,會被編譯器忽略。


然後,假如之後我們又引入此頭文件,編譯器讀到第一句條件編譯指令,


#ifndef MATH_H_INCLUDED


發現之前已經定義過了 MATH_H_INCLUDED,所以#ifndef MATH_H_INCLUDED不成立。因此不進入條件編譯的語句中。直接跳出。就不會再引入


int addTwo(int number);


了。很妙吧。


當然了,我們因爲用的是CodeBlocks這樣的IDE,它幫你自動生成了這三句條件編譯指令。假如我們用的是文本編輯器,那是沒有這三句自動生成的語句的。就需要我們自己手工寫了。


MATH_H_INCLUDED可以改成其他任何文本,只需要保證這三句條件指令中這個文本是一樣的就行,不過也要保證沒有兩個頭文件是使用同樣的文本。


注意。我們以上的函數原型其實就是源文件中函數定義的頭一行,只不過沒有了大括號包括起來的函數體,而且在參數列表的之後多了一個分號。


以上演示的是簡單的情況,是在函數的參數是普通變量類型時。假如我們要使用諸如string這樣的類型。那麼頭文件有些許改動哦,如下:


#ifndef MESSAGE_H_INCLUDED
#define MESSAGE_H_INCLUDED

#include <string>

void displayMessage(std::string message);

#endif // MESSAGE_H_INCLUDED


可以看到,需要在函數原型之前加 #include <string>這句指令,表示要引入string這個C++標準庫的頭文件。而且,在參數列表中,string類型前也要加上std::


爲什麼呢?


還記得我們之前在使用cout,cin時,並沒有再前面加上std:: 嗎?


那是因爲我們在使用cout,cin前,已經用了


using namespace std;


表示使用std命名空間(關於命名空間,之後的課程會學習)。


因爲cout和cin是位於命名空間std中,因此就可以省略了std::


但是,這裏的頭文件中並沒有 using namespace std; 這句指令(絕對不建議把using namespace std; 放在頭文件中,要放到源文件中),因此我們需要在string前加上std::,因爲string也位於std命名空間。


現在,我們來測試一下addTwo函數吧。


我 們只需要在main.cpp中加入一行 #include "math.h",表示引入math.h頭文件,而其定義是在math.cpp中,但是因爲math.cpp中已經有了#include "math.h",因此我們在main.cpp中就不要指定math.cpp來引入了。(是否已經暈了...)


#include <iostream>
#include "math.h"
using namespace std;

int main()
{
    int a(2), b(3);
    cout << "a的值是 : " << a << endl;
    cout << "b的值是 : " << b << endl;
    b = addTwo(a);        // 函數調用
    cout << "a的值是 : " << a << endl;
    cout << "b的值是 : " << b << endl;
    return 0;
}


運行,顯示:


a的值是 : 2

b的值是 : 3

a的值是 : 2

b的值是 : 4


0?wx_fmt=jpeg


好了,現在我們真正做到了將可重用的磚塊分開存放了。如果之後你想要在另一個項目中使用addTwo函數,只需要拷貝math.h和math.cpp這兩個文件過去就可以了。然後在要使用的源文件中寫上 #include "math.h文件所在的路徑/math.h"


我們也可以在同一個文件中定義多個函數。一般來說,我們都會把同一類函數放到同樣的文件中,比如那些數學運算的函數放到一個文件中,那些用於顯示菜單的函數放到另一個文件中,等等。


編程,就要有條理,有規劃。


好的註釋是代碼的一半


最煩看到大型項目中代碼不怎麼寫註釋的了,特別是那種許多人合作開發的項目,大家的命名規範和編程風格不盡相同,如果沒有註釋,看起來相當累。


一般不寫註釋的程序員不是好程序員,只顧自己寫代碼寫得爽,完全不關心後來人是不是被他的代碼拍死在沙灘上。應該被拖出去高速揉臉五分鐘(這是什麼呆萌的刑罰,那畫面太美我不敢看):


0?wx_fmt=jpeg


寫註釋對於函數特別有用。因爲你很可能會用到別的程序員寫的函數。那麼如果已經有了對函數的作用等的註釋,你一般就不需要讀函數的所有代碼了(程序員是會"偷懶"的)。


對於一個函數,一般我們的註釋要包含三方面:

  1. 函數的功用

  2. 參數列表

  3. 返回值


我們就來給addTwo函數寫個註釋吧:


#ifndef MATH_H_INCLUDED
#define MATH_H_INCLUDED

/*
 * 對參數進行加2操作
 * - number : 要進行加2操作的數
 * 返回值 : number + 2
 */
int addTwo(int number);

#endif // MATH_H_INCLUDED


寫註釋的格式隨個人愛好不盡相同。不顧一般常用的註釋格式是這樣(doxygen的格式):


/** 
 * \brief 對參數進行加2操作
 * \param number 要進行加2操作的數
 * \return number + 2
 */
 int addTwo(int number);



參數的默認值


函數的參數,我們已經學習過了。如果一個函數有三個參數,那麼我們需要向函數提供這三個參數的值,才能讓函數被正常調用。


但是,也並不一定。我們用以下函數來學習一下默認函數參數的用法:


int secondNumber(int hours, int minutes, int seconds)
{
    int total = 0;
    total = hours * 60 * 60;
    total += minutes * 60;
    total += seconds;
    return total;
}


這個函數的作用就是根據給出的小時數,分鐘數,秒數,計算出一共有多少秒。非常好理解。


因此,我們說hours, minutes, seconds是函數secondNumber的三個參數。在函數被調用時,我們須要給它這三個參數一定的值。



這個我們之前早就學過了。


參數默認值


我們要學習新知識點,就是我們其實可以給函數的參數指定默認值。假如函數在被調用時這些參數沒有被指定值,那麼就用默認的值。


首先來看沒有指定默認值的情況:


#include <iostream>
using namespace std;

// 函數原型
int secondNumber(int hours, int minutes, int seconds);

// 主函數
int main()
{
    cout << secondNumber(1, 10, 25) << endl;
    return 0;
}

// 函數定義
int secondNumber(int hours, int minutes, int seconds)
{
    int total = 0;
    total = hours * 60 * 60;
    total += minutes * 60;
    total += seconds;
    return total;
}


運行,顯示:


4225


因爲 1小時等於3600秒,10分鐘等於600秒,25秒等於... 25秒。所以 3600 + 600 + 25 = 4225


現在,假如我們要將secondNumber函數的部分參數設爲有默認值的參數。例如,我指定分鐘數minutes和seconds默認都爲0,因爲一般我們都用整點比較多,就只有小時數。


我們須要改寫函數的原型,如下所示:


int secondNumber(int hours, int minutes = 0, int seconds = 0);


看到了嗎?在minutes和seconds後面,都多了


= 0


這個就是函數的默認參數值。此處都設爲0。測試如下:


#include <iostream>
using namespace std;

// 函數原型
int secondNumber(int hours, int minutes = 0, int seconds = 0);

// 主函數
int main()
{
    cout << secondNumber(1) << endl;
    return 0;
}

// 函數定義,沒有寫參數默認值
int secondNumber(int hours, int minutes, int seconds)
{
    int total = 0;
    total = hours * 60 * 60;
    total += minutes * 60;
    total += seconds;
    return total;
}


運行,顯示:


3600


這是因爲hours爲1,而minutes和seconds沒指定,默認爲0。1小時等於3600秒。


請注意:函數參數的默認值只能寫在函數的原型中,假如寫在函數的定義中,編譯器會報錯。


假如改爲:


cout << secondNumber(1,10) << endl;


則輸出:


4200


因爲hours爲1;minutes爲10;seconds沒指定,默認爲0


所以3600 + 600 = 4200


特殊情況


函數的默認參數的使用中,會出現多種情況,有些比較特殊,我們舉例如下(還是以secondNumber函數爲例):


1.假如我指定了hours和seconds的值,但是minutes的值沒指定,那麼如何做呢?


你不可以使用這樣的形式:


cout << secondNumber(1,,25) << endl;


這樣是會出錯的。在C++中,我們不能跳過參數,即使它有默認值。如果我們給開頭和結尾的參數賦了值,那麼中間的參數也需要賦值。


還是得這麼寫:


cout << secondNumber(1, 0, 25) << endl;


2.這樣寫可以嗎?


int secondNumber(int hours = 0, int minutes, int seconds);


不可以,會出錯。因爲參數的默認值必須要靠右寫。也就是說,假如只有hours這個參數有默認值,那麼必須要把hours參數寫到最右邊,如下所示:


int secondNumber(int minutes, int seconds, int hours = 0);


這樣就沒有錯誤了。


3.我能否將所有參數都設爲有默認值?


可以。舉例如下:


int secondNumber(int hours = 0, int minutes = 0, int seconds = 0);
cout << secondNumber() << endl;


輸出就是0了。


設置默認參數的兩條主要規則


  1. 只有函數原型才能包含參數的默認值。

  2. 參數的默認值是寫在參數列表之後。而且是靠右邊寫。



總結


  1. 一個函數可以接收數據(通過參數),也可以返回數據(通過retur函數可以接收引用作爲參數,以直接修改內存中的信息。

  2. 當你的程序漸漸多起來時,建議將其分爲不同的文件。每個文件中存放特定的函數,而且文件是成對組織的:.cpp文件存放函數的定義,.h文件存放函數的原型。

  3. 一定要寫好註釋。



第一部分第九課預告


今天的課就到這裏,一起加油吧!

下一課我們學習:數組威武,動靜合一

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