Android NDK入門:C++ 基礎知識
- 2020 年 4 月 1 日
- 筆記

為什麼寫這篇文章
本文算作是 《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 |
其中 signed 和 unsigned 指定了數據是否有正負; short 和 long 主要指定了數據的內存大小。
由於不同的系統,同個數據類型所佔用的內存大小也不一定是一樣的,以下是典型值:
|
類型 |
內存大小 |
範圍 |
|---|---|---|
|
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 是訪問修飾符, 是 public、protected 或 private 其中的一個。
訪問修飾符的作用如下:
公有繼承(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指向的地址存儲的內容: %dn", 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, %dn", a.i, b.i, c->i); // 輸出:1, 1, 1 //-----2------- b.i = 2; //修改 b 中的變量 printf("%d, %d, %dn", a.i, b.i, c->i); // 輸出:1, 2, 1 //-----3------- c->i = 3; //修改 c 中的變量 printf("%d, %d, %dn", a.i, b.i, c->i); // 輸出:3, 2, 3 //-----4------- // 打印地址 printf("%d, %d, %dn", &a, &b, c); // 輸出:-1861360224, -1861360208, -1861360224 return 0; }
上面的例子,定義了一個變量 a ,然後將 a 分別賦值給普通變量 b 和指針變量 c。
第一次,打印三個變量中的成員變量的 i 的值都為 1;
第二次,修改了 b 中的 i,結果只修改了 b 的值,對 a 和 c 都沒有影響;
第三次,修改了 c 中的 i,結果修改了 a 和 c 的值,對 b 都沒有影響;
最後,打印了三個變量的地址,可以發現 a 和 c 的值是一樣的,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("%dn", a->i); // 輸出: 0 // 刪除指針變量,回收內存 delete a; return 0; }
這就是動態創建指針變量的方式,這是 C++ 常用的方式。
重要提醒:
要注意的是,通過
new的方式創建的指針變量和不通過new創建的變量最大的區別在於:通過new創建的指針需要我們自己手動回收內存,否則將會導致內存泄漏。回收內存則是通過delete關鍵字進行的。也就是說,
new和delete必須要成對調用。
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, %dn", 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, %dn", i, j, k); // 輸出:1, 1, 1 // 打印地址 printf("%d, %d, %dn", &i, &j, &k); // 輸出:-977299952, -977299952, -977299948
可以看到,i j k 三個的值都變成了 1,這看起來和指針是一樣的效果,但卻有質的區別。
看最後一個打印輸出,i 和 j 的地址始終是一樣的,和 k 是不一樣的。也就是說, j 始終指向 i ,不可改變。 j = k 只是把 k 的值給到了 j,同時也改變了 i 。
如果還不懂,再來看一下指針的例子,你就明白了。
int i = 0; // 定義指針 j ,指向 i int *j = &i; int k = 1; // 指向另一個對象 j = &k; printf("%d, %d, %dn", i, *j, k); // 輸出:0, 1, 1 // 打印地址 printf("%d, %d, %dn", &i, j, &k); // 輸出:-1790365184, -1790365180, -1790365180
看到了嗎? j 在賦值了 &k 以後,地址就變成和 k 一樣了,也就是說,指針 j 可以指向不同的對象。這時候, j 和 i 就沒有任何關係了,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("an"); }; }; class B : public A { public: void f() { printf("bn"); }; }; int main() { A *a = new B(); a->f(); // 輸出:a return 0; }
這裡 B 繼承了 A,並重寫了方法 f 。
在 main 函數中,定義了一個基類變量指針 a ,並指向子類 B 。接着調用了 a 的方法 f。
如果是 Java 中類似的操作的話,那麼毫無疑問,此處會輸出 b,可是這裡卻輸出了 a 。也就是說,這裡方法 f 實際上是基類 A 的 f 方法。
這就是 C++ 和 Java 其中一個很大的不同。
原因是,調用函數 f() 被編譯器設置為基類中的版本,這就是所謂的靜態多態,或靜態鏈接。
函數調用在程序執行前就準備好了。有時候這也被稱為早綁定,因為 f() 函數在程序編譯期間就已經設置好了。
那麼如果想實現類似 Java 中的多態重載呢?
虛函數
virtual 是 C++ 中的一個關鍵字,用於聲明函數,表示虛函數。用於告訴編譯器不要靜態鏈接到該函數,改為動態鏈接。
依然是上面的例子,在 A 的 f 函數上加上 virtual,將得到類似 Java 的效果:
class A { public: virtual void f() { printf("an"); }; }; class B : public A { public: void f() { printf("bn"); }; }; int main() { A *a = new B(); a->f(); // 輸出:b return 0; }
純虛函數
在 Java 中,我們經常會使用 interface 或 abstract 來定義一些接口,方便代碼規範和拓展,但是在 C++ 沒有這樣的方法,但是可以有類似的實現,那就是:純虛函數。
class A { public: // 聲明一個純虛函數 virtual void f() = 0; } class B : public A { public: // 子類必須實現 f ,否則編譯不通過 void f() { printf("bn"); }; }; 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 就是一個預處理,用於將其他文件導入到一個另一個文件中,類似 Java 的 import 。
例如導入頭文件:
// 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 DEBUGn"); #else // 不參與編譯 printf("No DEBUGn"); #endif return 0; } // 輸出:I am DEBUG
以上代碼,由於先前已經定義了 #define DEBUG 所以 #ifdef DEBUG 為 true ,編譯 printf("I am DEBUGn"); 。
如果去掉 #define DEBUG ,則編譯 printf("No DEBUGn"); 。
int main() { #if 0 // 這裏面的代碼都被注釋掉,不參與編譯 printf("I am not compiledn"); #endif return 0; }
七、總結
以上,基本就是在 C++ 經常使用到的,與 Java 相似,又存在差異的一些基礎知識,由於面向對象語言都存在一定的相似性,相信有了以上的基礎之後,你就可以比較通暢地閱讀一些 C++ 代碼了。
如果你是一個 Java 程序員,可能對其中的一些知識還是會感到迷惑,這時候需要你拋棄 Java 中的一些慣有思維,重新細細品嘗一下 C++ 的味道,可以實際的去敲一下代碼來消化這些知識,只有實踐才能出真知。

