Android NDK入門:C++ 基礎知識

開發的貓發表於2020-02-27

Android NDK入門:C++ 基礎知識

為什麼寫這篇文章

本文算作是 《Android 音視訊開發打怪升級》系列文章的“番外”篇,原本打算將本文的內容寫在 《Android FFmpeg視訊解碼播放》 這篇文章中,因為要想學習 FFmpeg 相關知識,C++ 的基礎知識是必不可少的。

但是寫著寫著發現,篇幅還是太長了,加上有部分小夥伴對 C++ 可能也比較熟悉,所以把此節獨立成篇,更有利於不熟悉 C++ 的小夥伴學習檢視,熟悉的小夥伴也可以直接跳過。

C++ 相對於 Java 還是有許多的不同之處,對於沒有使用過 C++ 的人來說,如果要學習 NDK 開發,C++ 是第一道坎,必須要掌握。

本文通過對比的方式,把 C++Java 之間最基礎,也是最常使用知識的異同標記出來,方便大家學習。

當然了,本文只是重點對 C++ 中最常用的,也是重點的知識進行講解,如有時間,最好還是系統地學一下相關的基礎知識。

本文你可以瞭解到

本文使用對比的方式,將 C++ 與我們非常熟悉的 Java 進行對比學習,介紹 C++Java 使用的異同,幫助大家快速入門 C++

一、 C++ 基本資料型別

C++ 提供了一下幾種基礎資料型別

型別 關鍵字
布林型 bool
字元型 char
整型 int
浮點型 float
雙浮點型 double
無型別 void

同時,這些型別還可以被型別修飾符修飾,擴充出更多的資料型別:

型別修飾符 關鍵字
有符號型別 signed
無符號型別 unsigned
短型別 short
長型別 long

其中 signedunsigned 指定了資料是否有正負; shortlong 主要指定了資料的記憶體大小。

由於不同的系統,同個資料型別所佔用的記憶體大小也不一定是一樣的,以下是典型值:

型別 記憶體大小 範圍
char 1 個位元組 -128到127 或 0到255
unsigned char 1 個位元組 0 到 255
signed char 1 個位元組 -128 到 127
int 4 個位元組 -2147483648 到 2147483647
unsigned int 4 個位元組 0 到 4294967295
signed int 4 個位元組 -2147483648 到 2147483647
short int 2 個位元組 -32768 到 32767
unsigned short int 2 個位元組 0 到 65,535
signed short int 2 個位元組 -32768 到 32767
long int 8 個位元組 -xxx 到 xxx
signed long int 8 個位元組 -xxx 到 xxx
unsigned long int 8 個位元組 -xxx 到 xxx
float 4 個位元組 -xxx 到 xxx
double 8 個位元組 -xxx 到 xxx
long double 16 個位元組 -xxx 到 xxx

可以看到,

short 修飾符將原型別記憶體大小減小一半;

long 修飾符將原資料型別記憶體大小擴大一倍。

二、C++ 類

C++ 是一門物件導向的語言,類是必不可少的。其類的定義與 Java 大同小異。

Java 類通常宣告和定義通常都是在同一個檔案 xxx.java 中。

C++ 類的宣告和定義通常是分開在兩個不同的檔案中,分別是 .h 標頭檔案.cpp 檔案

定義一個類

一個 類的標頭檔案 通常如下:

// A.h

class A
{
private: //私有屬性
    int a; 
    void f1();

protected: //子類可見
    int b;
    void f2(int i);

public: //公開屬性
    int c = 2;
    int f3(int j);
    
    A(int a, int b); // 建構函式
    ~A(); //解構函式

};
複製程式碼

對應的類實現檔案 A.cpp如下:

// A.cpp

/**
 * 實現建構函式
 */
A::A(int a, int b): 
a(a),
b(b) {
    
}

// 等價於

/*
A::A(int a, int b) {
    this.a = a;
    this.b = b;
}
*/

/**
 * 實現解構函式
 */
A::~A() {
    
}

/**
 * 實現 f1 方法
 */
void A::f1() {

    
}

/**
 * 實現 f2 方法
 */
void A::f2(int j) {
    this.b = j
}

/**
 * 實現 f3 方法
 */
int A::f3(int j) {
    this.c = j
}
複製程式碼

可以看到,.h 檔案主要負責類成員變數和方法的宣告; .cpp 檔案主要負責成員變數和方法的定義。

但是,並非一定要按照這樣的結構去實現類,你也可以在 .h 標頭檔案中直接定義變數和方法。

比如:

// A.h

class A {
private:
    int a = 1;
    
public:
    void f1(int i) {
        this.a = i;
    }
}
複製程式碼

C++ 類中幾個特別的地方

1) 可見性 private、protected、public

這幾個關鍵字和 Java 是一樣的,只不過在 C++ 中,通常不會對每個成員變數和方法進行可見性宣告,而是將不同的可見性的變數和方法集中在一起,統一宣告,具體見上面定義的類A。

2) 建構函式和解構函式

C++ 中類的建構函式和 Java 基本一致,只不過,在實現建構函式時,對成員變數的初始化方式比較特別。如下:

A::A(int a, int b): 
a(a),
b(b) {
    
}

// 等價於

A::A(int a, int b) {
    this.a = a;
    this.b = b;
}
複製程式碼

以上兩種方式都可以,通常使用第一種方式。

解構函式 則是 Java 中沒有的。通過波浪符號 ~ 進行標記。

它和建構函式一樣,都是由系統自動呼叫,只不過,建構函式 在類建立的時候呼叫,解構函式 在類被刪除的時候呼叫,主要用於釋放內部變數和記憶體。

解構函式的宣告形式為 ~類名();

實現的形式為 類名::~類名() { }

具體見上面類 A 的寫法。

3) :: 雙冒號

看了上面類的定義,肯定會對 :: 這個符號感到很神奇。這是 C++ 中的 域作用符,用於標示變數和方法是屬於哪個域的,比如上面的

void A::a() { }
複製程式碼

說明 方法a 是屬於 類A 的。

也可以用於呼叫類的靜態成員變數,如

//A.h

class A {
private:
    static int a = 1;
    int b;
    void a();
}

//A.cpp
void A::a() {
    b = A::a;
}
複製程式碼

類的繼承

C++ 類的繼承和 Java 也是大同小異,其格式如下:

class B: access-specifier A,其中 access-specifier 是訪問修飾符, 是 publicprotectedprivate 其中的一個。

訪問修飾符的作用如下:

公有繼承(public):當一個類派生自公有基類時,基類的公有成員也是派生類的公有成員,基類的保護成員也是派生類的保護成員,基類的私有成員不能直接被派生類訪問,但是可以通過呼叫基類的公有和保護成員來訪問。

保護繼承(protected): 當一個類派生自保護基類時,基類的公有和保護成員將成為派生類的保護成員。

私有繼承(private):當一個類派生自私有基類時,基類的公有和保護成員將成為派生類的私有成員。

通常情況下,我們都是使用 公有繼承(public),也就是和 Java 是一樣的。

類可以多繼承

Java 中,子類只能繼承一個父類,但是 C++ 可以繼承自多個父類,使用逗號 , 隔開:

class <派生類名>:<繼承方式1><基類名1>,<繼承方式2><基類名2>,…
{
<派生類類體>
};
複製程式碼

三、 C++ 指標

Java 中的 “指標”

Java 中,是沒有指標的概念的,但是其實 Java 中除了基本資料類,大部分情況下使用都是 指標

比如下面這段 Java 程式碼:

People p1 = new People("David","0001");
People p2 = p1;
p2.setName("Denny");
System.out.println(p1.getName());

// 輸出結果為:Denny
複製程式碼

原因就是 p1 和 p2 都是對物件的引用,在完成賦值語句 People p2 = p1; 後, p2 和 p1 指向同一個儲存空間,所以對於p2的修改也影響到了p1。

那麼,為什麼在 Java 中很少去關注指標呢?

因為 Java 已經將指標封裝了,也不允許顯式地去操作指標,並且 Java 中的記憶體都由虛擬機器進行管理,無需我們去釋放申請的記憶體。

C++ 中的指標

1) 指標的宣告和定義

Java 不同的是,C++ 中的指標概念非常重要,並且無處不在。

指標:是一個變數,這個變數的值是另一個變數的記憶體地址。也就是說,指標是一個指向記憶體地址的變數。

指標的宣告和定義方法如下:

int a = 1; // 實際變數的宣告

int *p; // 指標變數的宣告

p = &a; // 指標指向 a 的記憶體地址

printf("p 指向的地址: %d, p指向的地址儲存的內容: %d\n", p, *p);

// 輸出如下:
// p 指向的地址: -1730170860, p指向的地址儲存的內容: 1
複製程式碼

這個例子中有兩個很重要的符號: *&。其中:

* :有兩個作用:

i. 用於定義一個指標: type *var_name;var_name 是一個指標變數,如 int *p;

ii. 用於對一個指標取內容: *var_name, 如 *p 的值是 1

& :是一個取址符號

其用於獲取一個變數所在的記憶體地址。如 &a; 的值是 a 所在記憶體的位置,即 a 的地址。

通過上面的例子,可能無法很好的理解指標的用處,來看另一個例子。

class A {
public:
    int i;
};

int main() {
    
    //-----1-------
    A a = A(); // 定變數 a
    a.i = 1;   // 修改 a 中的變數
    
    A b = a;   // 定義變數 b ,賦值為 a
    
    A *c = &a; // 定義指標 c,指向 a
    
    printf("%d, %d, %d\n", a.i, b.i, c->i);
    // 輸出:1, 1, 1
    
    //-----2-------
    b.i = 2; //修改 b 中的變數
    
    printf("%d, %d, %d\n", a.i, b.i, c->i);
    // 輸出:1, 2, 1
    
    //-----3-------
    c->i = 3; //修改 c 中的變數
    
    printf("%d, %d, %d\n", a.i, b.i, c->i);
    // 輸出:3, 2, 3
    
    //-----4-------
    // 列印地址
    printf("%d, %d, %d\n", &a, &b, c);
    // 輸出:-1861360224, -1861360208, -1861360224
    
    return 0;
}
複製程式碼

上面的例子,定義了一個變數 a ,然後將 a 分別賦值給普通變數 b 和指標變數 c

第一次,列印三個變數中的成員變數的 i 的值都為 1

第二次,修改了 b 中的 i,結果只修改了 b 的值,對 ac 都沒有影響;

第三次,修改了 c 中的 i,結果修改了 ac 的值,對 b 都沒有影響;

最後,列印了三個變數的地址,可以發現 ac 的值是一樣的,b 的地址不一樣。

從這個例子就可以看出端倪了:

通過 普通變數 賦值的時候,系統建立了一個新的獨立的記憶體塊,如 b,對 b 的修改,隻影響其本身;

通過 指標變數 賦值時,系統沒有建立新的記憶體塊,而是將指標指向了已存在的記憶體塊,如 c , 任何對 c 的修改,都將影響原來的變數,如 a

還有一點需要注意的是,指標變數 對成員變數的引用,使用的是箭頭符號 ->,如 c->i ;普通變數對成員變數的引用,使用的是點符號 . ,如 b.i

2) new 和 delete

在上面的例子中,是通過建立了一個變數 a ,然後將 指標變數 c 指向了 a 的方式定義了 c。還有另外一種方法,可以宣告和定義一個指標變數,那就是通過 new 動態建立

class A {
public:
    int i;
}

int main() {
    A *a = new A();
    a->i = 0;
    
    printf("%d\n", a->i);
    // 輸出: 0
    
    // 刪除指標變數,回收記憶體
    delete a;
    
    return 0;
}
複製程式碼

這就是動態建立指標變數的方式,這是 C++ 常用的方式。

重要提醒:

要注意的是,通過 new 的方式建立的指標變數和不通過 new 建立的變數最大的區別在於:通過 new 建立的指標需要我們自己手動回收記憶體,否則將會導致記憶體洩漏。回收記憶體則是通過 delete 關鍵字進行的。

也就是說,newdelete 必須要成對呼叫

int main() {
    A a = A(); // 無new,main 函式結束後,系統會自動回收記憶體
    
    A *b = new A(); // new 方式建立,系統不會自動回收記憶體,要手動 delete
    
    delete b; // 手動刪除,回收記憶體
    
    return 0;
}
複製程式碼

可以看到,C++ 的指標變數其實更接近與 Java 中普通變數的使用方式。

四、C++ 引用

引用 是除了指標外,另一個非常重要的概念。在 C++ 也是經常使用的。

引用指的是:為一個變數起一個別名,也就是說,它是某個已存在變數的另一個名字。

引用和指標非常的相似,初學者非常容易把這兩者混淆了。

引用的宣告和定義

首先來看下如何宣告一個引用變數。

// 宣告一個普通變數 i
int i = 0;

// 宣告定義一個引用 j
int &j = i;

j = 1;

printf("%d, %d\n", i, j)

// 輸出:1, 1
複製程式碼

是不是有點熟悉,又是與符號 & ,但是這裡並非表示取址,這裡只是作為一個標示符號

請記住,千萬不要和取址符號混淆,取址表示方式是:A *p = &a;

在上面的例子中,修改了 j 的值,i 的值也發生了變化。這和指標是不是非常像?

那麼,引用和指標有什麼不一樣呢?

i. 不存在空引用。引用必須連線到一塊合法的記憶體。

ii. 一旦引用被初始化為一個物件,就不能被指向到另一個物件。指標可以在任何時候指向到另一個物件。

iii. 引用必須在建立時被初始化。指標可以在任何時間被初始化。

i 和 iii 都很好理解,就是宣告引用的時候,必須要初始化好,並且不能初始化為空 NULL

ii 是最讓人不理解的,什麼叫做 “不能被指向到另一個物件” ?

引用和指標的區別

看以下的例子:

int i = 0;

// 定義引用 j ,指向 i
int &j = i;

int k = 1// 這個操作是指向另外一個物件嗎?
j = k;

printf("%d, %d, %d\n", i, j, k);
// 輸出:1, 1, 1

// 列印地址
printf("%d, %d, %d\n", &i, &j, &k);
// 輸出:-977299952, -977299952, -977299948
複製程式碼

可以看到,i j k 三個的值都變成了 1,這看起來和指標是一樣的效果,但卻有質的區別。

看最後一個列印輸出,ij 的地址始終是一樣的,和 k 是不一樣的。也就是說, j 始終指向 i ,不可改變。 j = k 只是把 k 的值給到了 j,同時也改變了 i

如果還不懂,再來看一下指標的例子,你就明白了。

int i = 0;

// 定義指標 j ,指向 i
int *j = &i;

int k = 1;

// 指向另一個物件
j = &k;

printf("%d, %d, %d\n", i, *j, k);
// 輸出:0, 1, 1

// 列印地址
printf("%d, %d, %d\n", &i, j, &k);
// 輸出:-1790365184, -1790365180, -1790365180
複製程式碼

看到了嗎? j 在賦值了 &k 以後,地址就變成和 k 一樣了,也就是說,指標 j 可以指向不同的物件。這時候, ji 就沒有任何關係了,i 的值也不會隨著 j 改變而改變。

如何使用引用

引用最常出現的地方是作為函式的引數使用。

void change(int &i, int &j) {
    int temp = i;
    i = j;
    j = temp;
}

int main() {
    int i = 0;
    int j = 1;
    
    // 列印地址
    printf("[before: %d, %d]\n", &i, &j);
    //輸出:[before: -224237816, -224237812]
    
    change(i, j);
    
    printf("[i: %d, j: %d]\n", i, j);
    // 輸出:i: 1, j: 0
    
    // 列印地址
    printf("[after: %d, %d]\n", &i, &j);
    // 輸出:after: -224237816, -224237812
    
    return 0;
}
複製程式碼

在上面的例子中,change 方法的兩個引數都是引用,和普通的引數有以下兩個區別:

i. 引用引數不會建立新的記憶體塊,引數只是對外部傳進來的變數的一個引用。

ii. 引用引數可以改變外部變數的值。

這是普通變數的情況:

void change(int i, int j) {
    int temp = i;
    i = j;
    j = temp;
    
    // 列印地址
    pritf("[change: %d, %d]\n", &i, &j);
    // 輸出[change: -1136723044, -1136723048]
}

int main() {
    int i = 0;
    int j = 1;
    
    // 列印地址
    printf("[before: %d, %d]\n", &i, &j);
    //輸出:[before: -224237816, -224237812]
    
    change(i, j);
    
    printf("[i: %d, j: %d]\n", i, j);
    // 輸出:i: 0, j: 1
    
    // 列印地址
    printf("[after: %d, %d]\n", &i, &j);
    // 輸出:after: -224237816, -224237812
    
    return 0;
}
複製程式碼

可以看到,i j 的值不會被改不變,原因是 change 方法建立了兩個臨時的區域性變數,都有自己的記憶體塊,這個變數的地址和外部傳進來的變數是沒有關係的,所以無法改變外部變數的值。

到這裡,就可以看到引數引用的好處了:引用引數為我們節省了記憶體,執行效率也更快。

同樣的,指標引數也有類似的效果,但是其仍然和引用有著本質的區別。引用為我們提供另一個種很好的傳參選擇。

有時候,我們並不想讓函式內部改變外部變數的值,可以給引數加上常量的標誌。

void change(const int &i, const int &j) {
    int temp = i;
    i = j;     // 不允許修改i,編譯出錯
    j = temp;  // 不允許修改j,編譯出錯
}
複製程式碼

五、C++ 多型和虛擬函式

多型 是物件導向的三大特點之一。

C++ 的多型和 Java 非常相似,但是也有著明顯的不同。

靜態繫結

看下面一個例子:

class A {
public:
    void f() {
        printf("a\n");
    };
};

class B : public A {
public: 
    void f() {
        printf("b\n");
    };
};

int main() {
    A *a = new B();
    a->f();
    // 輸出:a
    
    return 0;
}
複製程式碼

這裡 B 繼承了 A,並重寫了方法 f

main 函式中,定義了一個基類變數指標 a ,並指向子類 B 。接著呼叫了 a 的方法 f

如果是 Java 中類似的操作的話,那麼毫無疑問,此處會輸出 b,可是這裡卻輸出了 a 。也就是說,這裡方法 f 實際上是基類 Af 方法。

這就是 C++Java 其中一個很大的不同。

原因是,呼叫函式 f() 被編譯器設定為基類中的版本,這就是所謂的靜態多型,或靜態連結

函式呼叫在程式執行前就準備好了。有時候這也被稱為早繫結,因為 f() 函式在程式編譯期間就已經設定好了。

那麼如果想實現類似 Java 中的多型過載呢?

虛擬函式

virtualC++ 中的一個關鍵字,用於宣告函式,表示虛擬函式。用於告訴編譯器不要靜態連結到該函式,改為動態連結

依然是上面的例子,在 Af 函式上加上 virtual,將得到類似 Java 的效果:

class A {
public:
    virtual void f() {
        printf("a\n");
    };
};

class B : public A {
public: 
    void f() {
        printf("b\n");
    };
};

int main() {
    A *a = new B();
    a->f();
    // 輸出:b
    
    return 0;
}
複製程式碼

純虛擬函式

Java 中,我們經常會使用 interfaceabstract 來定義一些介面,方便程式碼規範和擴充,但是在 C++ 沒有這樣的方法,但是可以有類似的實現,那就是:純虛擬函式

class A {
public:
    // 宣告一個純虛擬函式
    virtual void f() = 0;
}

class B : public A {
public: 
    // 子類必須實現 f ,否則編譯不通過
    void f() {
        printf("b\n");
    };
};

int main() {
    A *a = new B();
    a->f();
    // 輸出:b
    
    return 0;
}
複製程式碼

A 中的 virtual void f() = 0; 就是一個純虛擬函式。如果繼承 A,子類必須實現 f 這個介面,否則編譯不通過。

A 則是一個抽象類。不能被直接定義使用。

六、C++ 預處理

C++ 中有一個方法,可以讓我們在程式編譯前,對程式碼做一些處理,稱為預處理。這是 Java 中沒有的,在 C++ 中卻經常使用到。

預處理是一些指令,但是這些指令並不是 C++ 語句,所以不需要以分號 ; 結束。

所有的預處理語句都是以井號 # 開始的。

比如 #include 就是一個預處理,用於將其他檔案匯入到一個另一個檔案中,類似 Javaimport

例如匯入標頭檔案:

// A.h
class A{
public:
    A();
    ~A();
}
複製程式碼
#include "A.h"

A::A() {
    
}

A::~A() {
    
}
複製程式碼

C++ 中常用的預處理有以下幾個 #include#define#if#else#ifdef#endif 等。

巨集定義

最常用的一個預處理語句 #define ,通常稱為巨集定義

其形式為:

#define name replacement-text 
複製程式碼
#define PI 3.14159

printf("PI = %f", PI);

// 在編譯之前,上面的語句被展開為:
// printf("PI = %f", 3.14159);

複製程式碼
  • 帶引數巨集定義
#define SUM(a,b) (a + b)

printf("a + b = %d", SUM(1, 2));

// 在編譯之前,上面的語句被展開為:
// printf("a + b = %d", 1 + 2);
// 輸出:a + b = 3
複製程式碼
  • ### 運算子

在巨集定義中,# 用於將引數 字串化

#define MKSTR( x ) #x

printf(MKSTR(Hello C++));

// 在編譯之前,上面的語句被展開為:
// printf("Hello C++");
// 輸出: Hello C++
複製程式碼

在巨集定義中,## 用於將引數 連線起來

#define CONCAT(a, b) a ## b

int xy = 100;

printf("xy = %d", CONCAT(x, y));

// 在編譯之前,上面的語句被展開為:
// printf("xy = %d", xy);
// 輸出:xy = 100
複製程式碼

注意:### 在多個巨集定義巢狀使用的時候,會導致不展開的問題

例如:

#define CONCAT(x, y) x ## y

#define A a

#define B b

void mian() {

    char *ab = "ab";
    char *AB = "AB";
    
    printf("AB = %s", CONCAT(A, B));
    
    // 在編譯之前,上面的語句被展開為:
    // printf("AB = %s", AB);
}
複製程式碼

雖然定義了 A B 兩個巨集定義,但是在 CONCAT 中遇到 ## 的時候,A B 這兩個巨集定義是不會開展的,而是直接當作兩個引數被連線起來了。

那麼要如何解決這個問題呢?那就是再轉接一層。

#define _CONCAT(x, y) x ## y
#define CONCAT(x, y) _CONCAT(x, y)

#define A a

#define B b

void mian() {

    char *ab = "ab";
    char *AB = "AB";
    
    printf("AB = %s", CONCAT(A, B));
    
    // 在編譯之前,上面的語句被展開為:
    // printf("AB = %s", _CONCAT(a, b));
    // printf("AB = %s", ab);
    // 輸出:AB = ab
}
複製程式碼

條件編譯

#if#else#ifdef#endif 這幾個的組合主要用條件編譯。

C++ 中條件編譯也是經常使用到的,可以用來控制哪些程式碼參與編譯,哪些不參與編譯。

#define DEBUG

int main() {

#ifdef DEBUG
    // 參與編譯
    printf("I am DEBUG\n");
#else
    // 不參與編譯
    printf("No DEBUG\n");
#endif

    return 0;
}

// 輸出:I am DEBUG
複製程式碼

以上程式碼,由於先前已經定義了 #define DEBUG 所以 #ifdef DEBUGtrue ,編譯 printf("I am DEBUG\n");

如果去掉 #define DEBUG ,則編譯 printf("No DEBUG\n");

int main() {

#if 0
    // 這裡面的程式碼都被註釋掉,不參與編譯
    printf("I am not compiled\n");
#endif

    return 0;
}
複製程式碼

七、總結

以上,基本就是在 C++ 經常使用到的,與 Java 相似,又存在差異的一些基礎知識,由於面嚮物件語言都存在一定的相似性,相信有了以上的基礎之後,你就可以比較通暢地閱讀一些 C++ 程式碼了。

如果你是一個 Java 程式設計師,可能對其中的一些知識還是會感到迷惑,這時候需要你拋棄 Java 中的一些慣有思維,重新細細品嚐一下 C++ 的味道,可以實際的去敲一下程式碼來消化這些知識,只有實踐才能出真知。

推薦兩個網站:

C++ 菜鳥教程

C++ 線上編譯

Android NDK入門:C++ 基礎知識

相關文章