[C++]變數宣告與定義的規則

番茄貓發表於2021-04-18

宣告與定義分離

Tips:變數能且僅能被定義一次,但是可以被多次宣告。

為了支援分離式編譯,C++將定義和宣告區分開。其中宣告規定了變數的型別和名字,定義除此功能外還會申請儲存空間並可能為變數賦一個初始值。

extern

如果想宣告一個變數而非定義它,就使用關鍵字extern並且不要顯式地初始化變數:

extern int i;      // 宣告i而非定義i
extern int i = 1;  // 定義i, 這樣做抵消了extern的作用 

static

當我們在C/C++用static修飾變數或函式時,主要有三種用途:

  • 區域性靜態變數
  • 外部靜態變數/函式
  • 類內靜態資料成員/成員函式

其中第三種只有C++中有,我們後續在物件導向程式設計中再探討,這裡只討論靜態區域性/全域性變數。

1. 靜態區域性變數

在區域性變數前面加上static說明符就構成靜態區域性變數,例如:

// 宣告區域性靜態變數
static int a;
static int array[5] = {1, 2, 3, 4, 5};
  • 靜態區域性變數在函式內定義,但不像自動變數那樣當函式被呼叫時就存在,呼叫結束就消失,靜態變數的生存期為整個源程式
  • 靜態變數的生存期雖然為整個源程式,但是作用域與自動變數相同,即只能在定義該變數的函式內使用該變數,退出函式後雖然變數還存在,但不能夠使用它
  • 對基本型別的靜態區域性變數如果在宣告時未賦初始值,則系統自動賦0值;而對普通區域性變數不賦初始值,那麼它的值是不確定的

根據靜態區域性變數的特點,它的生存期為整個源程式,在離開定義它的函式(作用域)但再次呼叫定義它的函式時,它又可繼續使用,而且儲存了前次被呼叫後留下的值。因此,當多次呼叫一個函式且要求在呼叫之間保留某些變數的值時,可考慮採用靜態區域性變數,雖然用全域性變數也可以達到上述目的,但全域性變數有時會造成意外的副作用,因此最好採用區域性靜態變數。例如:

#include <iostream>

void foo() {
    int j = 0;         // 普通區域性變數
    static int k = 0;  // 靜態區域性變數
    ++j;
    ++k;
    printf("j:%d, k:%d\n", j, k);
}

int main(void)
{
    for (int i = 1; i <= 5; i++) {
        foo();
    }
}

// 輸出:
j:1, k:1
j:1, k:2
j:1, k:3
j:1, k:4
j:1, k:5

2. 靜態全域性變數(C++廢棄,用匿名名稱空間替代)

Tips:對於全域性變數,不管是否被static修飾,它的儲存區域都是在靜態儲存區,生存期為整個源程式。只不過加上static後限制這個全域性變數的作用域只能在定義該變數的原始檔內。

全域性變數(外部變數)的宣告之前加上static就構成了靜態的全域性變數,全域性變數本身就是靜態儲存變數,靜態全域性變數當然也是靜態儲存方式。這兩者在儲存方式上並無不同,這兩者的區別在於非靜態全域性變數的作用域是整個源程式。當一個源程式由多個源程式組成時,非靜態的全域性變數在各個原始檔中都是有效的,而靜態全域性變數則限制了其作用域,即只在定義該變數的原始檔內有效,在同一源程式的其他原始檔中不能使用它。

這種在檔案中進行靜態宣告的做法是從C語言繼承而來的,在C語言中宣告為static的全域性變數在其所在的檔案外不可見。這種做法已經被C++標準取消了,現在的替代做法是使用匿名名稱空間。

匿名名稱空間:指關鍵字namespace後緊跟花括號括起來的一系列宣告語句,具有如下特點:

  • 在匿名名稱空間內定義的變數具有靜態生命週期
  • 匿名空間在某個給定的檔案內可以不連續,但是不能跨越多個檔案
  • 每個檔案定義自己的匿名名稱空間,不同檔案匿名名稱空間中定義的名字對應不同實體
  • 如果在一個標頭檔案中定義了匿名名稱空間,則該名稱空間內定義的名字在每個包含該標頭檔案的檔案中對應不同實體
namespace {
    int i;  // 匿名名稱空間內定義的變數具有靜態生命週期, 作用域僅限於當前檔案
}

3. 總結

static這個說明符在不同地方所起的作用域是不同的,比如把區域性變數改變為靜態變數後是改變了它的儲存方式即改變了它的生存期,把全域性變數改變為靜態變數後是改變了它的作用域,限制了它的使用範圍。

auto

1. C++98中auto用法(C++11已廢棄)

C++98 auto用於宣告變數為自動變數(擁有自動的生命週期),C++11已經刪除了該用法,取而代之的是“變數的自動型別推斷方法”。

// c++ 98:
int a = 10;         // 擁有自動生命期
auto int b = 20;    // 擁有自動生命期(C++11編譯不過)
static int c = 30;  // 延長了生命期

C++11新標準引入了auto型別說明符,讓編譯器通過初始值來自動推斷變數型別(這意味著通過auto定義的變數必須有初始值)。

// c++ 11:
int a = 10;
auto auto_a = a;  // 自動型別推斷為int型別

2. auto會去除變數的引用語義

當引用物件作為初始值時,真正參與初始化的是引用物件的值,此時編譯器會以引用物件的型別作為auto推算的型別:

int main(void) {
    int i = 10;
    int &ri = i;
    auto auto_i = ri;  // 去除引用語義, 自動推斷為int
}

如果希望推斷出來的auto型別包含引用語義,我們需要用&明確指出:

int main(void) {
    int i = 10;
    auto &auto_i = i;  // 加上引用語義, 自動推斷為int&
}

3. auto忽略頂層const

auto一般會忽略掉頂層const,同時底層const會被保留下來:

int main(void) {
    const int ci = 10;    // 常量int
    auto auto_ci = ci;    // auto_ci被推斷為int型別
    auto_ci = 20;         // 正確: auto_ci非常量

    const int &cr = ci;   // cr是指向常量int的常量引用
    auto auto_cr = cr;    // auto_cr被推斷為int型別: 去除了引用語義 + 去除了頂層const
    auto_cr = 20;         // 正確: auto_cr非常量

    const int *cp = &ci;  // cp是指向常量int(底層)的常量指標(頂層)
    auto auto_cp = cp;    // auto_cp被推斷為const int*型別(指向常量int的指標): 去除了頂層const + 保留底層const
    // *auto_cp = 10;     // 錯誤: 不能修改auto_cp指向的常量
}

如果希望推斷出來的auto型別是一個頂層const,我們需要通過const關鍵字明確指出:

int main(void) {
    const int ci = 10;          // 常量int
    const auto auto_ci = ci;    // auto_ci被推斷為const int型別
    // auto_ci = 20;            // 錯誤: auto_ci是一個常量, 禁止修改
}

const

有時我們希望定義一個不能被改變值的變數,可以使用關鍵字const對變數型別加以限定。

1. const物件必須初始化

因為const物件一經建立後其值就不能再改變,所以const物件必須初始化,但是初始值可以是任意複雜的表示式:

const int i = get_size();  // 正確: 執行時初始化
const int j = 42;          // 正確: 編譯時初始化
const int k;               // 錯誤: k是一個未經初始化的常量

2. 預設情況下const僅在檔案內有效

舉個例子,我們在編譯時初始化一個const物件:

const int i = 10;

編譯器會在編譯過程把用到該變數的地方都替換為對應的值。為了執行這個替換,編譯器必須知道變數的初始值,如果程式包含多個檔案,那麼每個用了這個const物件的檔案都必須得能訪問到它的初始值才行(即每個檔案都要定義const物件)。為了避免對同一變數的重複定義,當多個檔案中出現同名的const物件時,其實等同於在不同檔案中分別定義了獨立的變數。

/*
 * 下面是合法的, 不存在變數i重複定義問題
 */

// foo.cpp
const int i = 10;

// bar.cpp
const int i = 5;

如果想在多個檔案之間共享const物件,那麼必須在變數的定義之前新增extern關鍵字:

/*
 * 下面是合法的, main.cpp和foo.cpp中的const int物件是同一個
 */

// foo.cpp
extern const int i = 10;

// main.cpp
#include <iostream>

int main(void) {
    extern int i;
    std::cout << "i:" << i << std::endl;
}

3. 允許常量引用繫結非常量物件、字面值甚至一般表示式

一般而言,引用的型別必須與其所引用物件的型別一致,但是有兩個例外:

  • 初始化常量引用時允許用任意表示式作為初始值,只要該表示式的結果能轉換成引用型別即可,允許為一個常量引用繫結非常量的物件、字面值甚至是一個一般表示式(如下)
  • 可以將基類的指標或引用繫結到派生類物件上(後續物件導向章節再探討)
int i = 10;

const int &ri1 = i;      // 合法: 繫結到非常量物件
const int &ri2 = 100;    // 合法: 繫結到字面值
const int &ri3 = 1 + 1;  // 合法: 繫結到一般表示式

4. 頂層const與底層const

指標本身是一個物件,因此指標本身是不是常量與指標所指物件是不是常量是兩個獨立的問題,前者被稱為頂層const,後者被稱為底層const。

Tips:指標型別既可以是頂層const也可以是底層const,其他型別要麼是頂層常量要麼是底層常量。

頂層const用於表示任意的物件是常量,包括算數型別、類和指標等,底層const用於表示引用和指標等複合型別的基本型別部分是否是常量。

int i = 10;

int *const p1 = &i;        // 頂層const: 不能改變p1的值
const int *p2 = &i;        // 底層const: 不能通過p2改變i的值
const int *const p3 = &i;  // 底層const + 頂層const

const int &r1 = i;         // 底層const: 不能通過r1改變i的值

constexpr

C++11引入了常量表示式constexpr的概念,指的是值不會改變並且在編譯期間就能得到計算結果的表示式。

const int i = 10;          // 常量表示式
const int j = i + 1;       // 常量表示式
const int k = size();      // 僅當size()是一個constexpr函式時才是常量表示式, 執行時才能獲得具體值就不是常量表示式

在一個複雜系統中,我們很難分辨一個初始值是否是常量表示式,通過constexpr關鍵字宣告一個變數,我們可以讓編譯器來驗證變數的值是否是一個常量表示式。

1. 字面值是常量表示式

算術型別、引用和指標都屬於字面值型別,自定義類則不屬於字面值型別,因此也無法被定義為constexpr。

Tips:儘管指標和引用都能被定義成constexpr,但它們的初始值卻受到嚴格限制。一個constexpr指標的初始值必須是nullptr、0或者是儲存於某個固定地址中的物件。

2. constexpr是對指標的限制

在constexpr宣告中定義了一個指標,限定符constexpr僅對指標有效,與指標所指物件無關:

const int *pi1 = nullptr;      // 底層const: pi1是指向整型常量的普通指標
constexpr int *pi2 = nullptr;  // 頂層const: pi2是指向整型的常量指標

我們也可以讓constexpr指標指向常量:

constexpr int i = 10;
constexpr const int *pi = &i;  // 頂層const + 底層const 

Reference

[1] https://www.cnblogs.com/lca1826/p/6503194.html

[2] https://blog.csdn.net/u012679707/article/details/80188124

[3] C++ Primer

相關文章