首頁 > 軟體

C/C++程式設計的基本概念詳解

2021-09-27 13:01:02

概述

學C語言有很長一段時間了,想做做筆記,把C和C++相關的比較容易忽視的地方記下來,也希望可以給需要的同學一些幫助。

我的這些文章不想對C和C++的語法進行講解和羅列,這些東西隨便找一本書就講的比我清楚,我只是想把一般人忽視的地方儘自己所能描述一下。權當班門弄斧,貽笑大方了。

首先我想先從C和C++的一些基本概念入手。

main()函數

稍微學過C和C++的人都知道main()函數市所有C和C++程式必不可少的東西。叫做主函數。所有的程式都應該從main()函數開始執行。但是你們又對這個函數了解多少呢?

我們都知道C和C++是一種函數語言,幾乎絕大多數的功能都是通過各種函數的呼叫來實現的,C和C++也提供了豐富的函數庫供程式設計人員呼叫。可雖然main()函數每個C程式都必須有的函數,在C或者C++的函數庫裡卻沒有叫做main()的函數,它是需要程式設計人員實現的函數。

而且,你們發現了沒有,main並不是C和C++的保留字。因此理論上,你可以在其他地方使用main這個名字,比如變數名、類名字、名稱空間的名字甚至成員函數的名字。但是,即使這樣,你也不能修改main()函數本身的函數名,否則聯結器就會報告錯誤。

main()函數是C和C++程式的入口,這是因為C和C++語言實現會有一個啟動函數,比如MS-C++的啟動函數就叫做

mainCRTStartup()或者WinMainCRT-Startup()。在這個啟動函數的最後會呼叫main()函數,然後再呼叫exit()函數結束程式。如果沒有main()函數,當然會報錯了。所以再C和C++開發環境中main()函數其實是一個回撥函數。它是需要我們來實現的。

有些同學可能學過一些應用程式框架,比如MFC什麼的。這些程式程式碼中往往找不到main()函數,這是因為那些應用程式框架把main()函數的實現給隱藏起來了,main()函數在它們這裡有固定的實現模式,所以不需要我們編寫。在連線階段,框架會自動將包含main()實現的庫加進來連線。

main()函數也是有原型的。這個原型已經是一種標準了,在ISO/IEC14882中對main()的原型進行了定義。

	int main(){/*......*/}
和
	int main(int argc, char *argv[]){/*......*/}

上面這兩種形式是最具有可移植性的正確寫法。當然不同的編譯器可能會允許出現一些擴充套件。比如允許main()返回void,或者有第三個引數char *env[]什麼的。這個就要看具體的編譯器檔案了。

關於返回值,我們知道main()返回的是int型別的。到底返回什麼是有不同含義的。一般情況下,返回0,表示程式正常結束,返回任何非0表示錯誤或非正常退出。前面講到了,啟動函數最後還會呼叫exit()函數。那麼main()函數的返回值就會作為exit()函數的運算元來返回作業系統。

在C++當中對main()函數還有一些特殊的限制。比如:

  • 不能過載
  • 不能內聯
  • 不能定義為靜態的
  • 不能取其地址
  • 不能由使用者自己呼叫

關於main()函數的引數,它可以讓編譯好的執行程式具有處理命令列引數的能力。這裡需要注意,不要把「命令列引數」和main()函數的「函數實參」混淆,這是兩個不同的概念。命令列引數由啟動程式截獲並打包成字串陣列傳遞給main()的形參argv[],而包括命令字(也就是執行檔案自己的名字)在內的所有引數的個數則被傳遞給形參argc。試一下吧,咱們來模擬copy命令寫個簡單的檔案拷貝程式。

//mycopy.c:檔案拷貝程式。
#include <stdio.h>
int main(int argCount, char* argValue[])
{
    FILE *srcFile = 0;
    FILE *destFile = 0;
    int ch = 0;
    if(argCount != 3)
    {
        printf("使用方法:%s 原檔案 目標檔案n",argValue[0]);
    }
    else
    {
        if((srcFile = fopen(argValue[1],"r")) == 0)
        {
            printf("無法開啟原檔案!"%s"!",argValue[1]);
        }
        else
        {
            if((destFile = fopen(argValue[2],"w")) == 0)
            {
                printf("無法開啟目標檔案!"%s"!",argValue[2]);
                fclose(srcFile);
            }
            else
            {
                while((ch = fgetc(srcFile)) != EOF)
                {
                    fputc(ch,destFile);
                }
                fclose(srcFile);
                fclose(destFile);
                return 0;
            }
        }
    }
    return 1;
}
//用法:mycopy C:file1.dat D:newfile.dat

內部名稱

在編寫C程式的時候如果沒有main()函數,聯結器會報錯。一般報錯資訊會提示「unresolved external symbol_main」。這裡的"_main"其實就是編譯器為main生成的內部名稱。其實C和C++語言在編譯過程中都會按照特定的規則把使用者定義的識別符號(函數、變數、型別、名稱空間什麼的)轉換為相應的內部名稱。而這些規則還跟指定的連線規範有關。比如,在C語言中,main的內部名稱就叫做_main。

C語言這麼做,是告訴聯結器,這個東西是個函數。實際上,C語言在所有函數的函數名前其實都是加了字首「_」的,以此來區別函數名和其他識別符號名稱。

這種規範在C++又是另一種樣子。這是因為在C中,所有函數只要不是區域性於編譯單元(檔案作用域)的static函數,就會是具有extern連線型別的和global作用域的全域性函數。全域性函數是不可以有同名的。但是在C++裡面,可以在不同的作用域,比如class,struct,union,namespace中定義同名的函數,甚至在同一個作用域也可以定義同名函數,也就是函數過載。那麼轉換為內部名稱的連線規範就要複雜一些了。比如:

class Sample_1
{
	char m_name[16];
public:
	void foo(char *newName);
	void foo(int age);
};
class Sample_2
{
	char m_name[16];
public:
	void foo(char *newName);
	void foo(bool sex);
};

在其他地方根據這兩個類生成兩個範例,並進行操作:

	Sample_1 a;
	Sample_2 b;
	a.foo("aaa");
	a.foo(100);
	b.foo("bbb");
	b.foo(false);

這裡有四個函數,但是確是同一個名稱。編譯器應該怎麼區分呢?通過各自物件的成員識別符號區分?那是在程式碼中區分的,但是在聯結器看來,所有函數其實都是全域性函數,而全域性函數是不能重名的。所以為了避免二義,在C++中有一個名字修飾規則。也就是在函數名前面新增各級作用域的名稱以及過載函數經過編碼的引數資訊。比如上面四次呼叫foo函數,其實它們會呼叫四個具有全域性名稱的函數,分別是Sample_1_foo@pch@1,Sample_1_foo@int@1,Sample_2_foo@pch@1,Sample_2_foo@int@1。

然而,這種標準並不是強制的,所以不同廠家開發的C++編譯器有可能會有些許不同,而這也正是導致不同廠家的C++編譯器和聯結器不能相容的原因。

那麼好了,當使用不同程式語言聯合開發時候,就要定義一個統一的規範,這個規範叫做連線規範。這個很好理解了吧,因為如果同一個識別符號在不同編譯單元中用不同的連線規範,就會產生不一致的內部名稱,連線肯定會失敗。

所以,在開發程式庫的時候就一定要明確你要用那條連線規範。比如,編寫C程式是就要規定C連線規範:extern 「C」。大約有這麼幾種情況:

  • 僅對一個型別、函數、變數或常數指定連線規範;
    extern "C" void WinMainCRTStartup();
    extern "C" const CLSID CLSID_DataConverter;
    extern "C" struct Student{/*....*/};
    extern "C" Student g_Student;
  • 對一段程式碼限定連線規範
    #ifdef __cplusplus
    extern "C" {
    #endif
    const int MAX_AGE = 200;
    #pragma pack(push,4)
    typedef struct _Person
    {
        char *m_Name;
        int m_Age;
    } Person, *PersonPtr;
    #pragma pack(pop)
    Person g_Me;
    int __cdecl memcmp(const void*, const void*, size_t);
    void* __cdecl memcpy(void*, const void*, size_t);
    void* __cdecl memset(void*, int, size_t);
    #ifdef __cplusplus
    }
    #endif
  • 當前使用的是C++編譯器,並且使用了extern "C"限定了一段程式碼的連線規範,但又想在其中某行或某段程式碼保持C++的連線規範。
    #ifdef __cplusplus
    extern "C" {
    #endif
    const int MAX_AGE = 200;
    #pragma pack(push,4)
    typedef struct _Person
    {
        char *m_Name;
        int m_Age;
    } Person, *PersonPtr;
    #pragma pack(pop)
    Person g_Me;
    #if __SUPPORT_EXTERN_CPP_
    extern "C++"{
    #endif
    int __cdecl memcmp(const void*, const void*, size_t);
    void* __cdecl memcpy(void*, const void*, size_t);
    #if __SUPPORT_EXTERN_CPP_
    }
    #endif
    void* __cdecl memset(void*, int, size_t);
    #ifdef __cplusplus
    }
    #endif
  • 某個宣告中指定了某個識別符號的連線規範為extern 「C」,那麼對應的定義也要指定extern 「C」:
    #ifdef __cplusplus
    extern "C" {
    #endif
    memcmp(const void*, const void*, size_t);
    #ifdef __cplusplus
    }
    #endif
    #ifdef __cplusplus
    extern "C" {
    #endif
    memcmp(const void *p, const void *a, size_t len)
    {
        //功能實現
    }
    #ifdef __cplusplus
    }
    #endif

其實如果是面向介面的程式設計,就不用考慮這麼多了。因為即使介面兩端的內部名稱不同,只要使用了一致的成員對其和排列方式,並遵守一致的呼叫規範,一致的函數實現方式。也就是C++一致的物件模型,那麼基本不會有什麼問題的。

變數和它的初始化

在C和C++中,全域性變數(extern或static的)存放在程式的靜態資料區裡面。這些變數在程式進入main()之前就被建立了,並在main()結束後銷燬,C和C++提供了一個預設的全域性初始化器0。也就是編譯器會預設的用0來初始化它們。函數內部的static變數和類的static成員也是在靜態儲存區,因此也會預設初始化為0。除非你在建立的時候就提供了初值。這是編譯器對靜態變數的待遇。而對於其他的自動變數,就需要我們給他初始化了。不要指望編譯器自動對它們初始化。

所以,全域性變數的宣告和定義應當放在原始檔的開頭部位。

變數的初始化和變數的賦值是有區別的。初始化是發生在變數建立的同時,而賦值是在程式中變數建立後乾的。
前面說了,對靜態儲存區的變數(比如全域性變數,靜態變數什麼)的進行初始化是編譯器自動進行的,但是區域性變數的初始化確實需要程式設計人員手動進行。

還有,在一個程式設計單元中,全域性變數的初始值不要依賴另一個編譯單元的全域性變數。什麼意思?比如:

//file1.c
int g_x = 100;
//file2.c
extern int g_x;
double g_d = g_x + 10;

這兩個編譯單元編譯完成後進行連線,兩個全域性變數到底先初始化哪個並不確定,聯結器也不能保證這一點。先初始化g_x,那g_d也就能順利初始化,而反之,g_d就不一定是多少了。

另外,C和C++都會有現成的庫。就是檔案開頭包含的那些*.h檔案。注意哦,C的庫可是有多執行緒版和單執行緒版,開發多執行緒程式應該使用多執行緒版本的庫。另外,在多人開發軟體是,庫的版本一定要統一。

編譯時和執行時

原始碼檔案編寫的功能有些時執行時起作用,有些編譯時就起作用的。這件事需要區分的。比如預編譯偽指令、類定義、外部物件宣告、函數原型、修飾符號(const,static那些)、類成員存取說明符號(public、private那些)以及連線規範是在編譯階段發揮作用的,可執行程式裡是不存在這些東西的。而容器越界存取、虛擬函式動態決議、動態連線、動態記憶體分配、例外處理、RTTI這些則是在執行時發揮作用的。比如:

int* pInt = new int[10];
pInt += 100;
cout << *pInt << endl;
*pInt = 1000;

這段程式碼一般在編譯階段沒什麼問題,但執行時會出錯。所以,我們在程式設計時就要對執行的行為有所預見,通過編譯連線的程式在執行時不見得正確。

總結

本篇文章就到這裡了,希望能夠給你帶來幫助,也希望您能夠多多關注it145.com的更多內容!


IT145.com E-mail:sddin#qq.com