為什麼寫這篇文章
本文算作是 《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指向的地址儲存的內容: %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
的值,對 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("%d\n", 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, %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
,這看起來和指標是一樣的效果,但卻有質的區別。
看最後一個列印輸出,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, %d\n", i, *j, k);
// 輸出:0, 1, 1
// 列印地址
printf("%d, %d, %d\n", &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("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
實際上是基類 A
的 f
方法。
這就是 C++
和 Java
其中一個很大的不同。
原因是,呼叫函式 f() 被編譯器設定為基類中的版本,這就是所謂的靜態多型,或靜態連結。
函式呼叫在程式執行前就準備好了。有時候這也被稱為早繫結,因為 f() 函式在程式編譯期間就已經設定好了。
那麼如果想實現類似 Java
中的多型過載呢?
虛擬函式
virtual
是 C++
中的一個關鍵字,用於宣告函式,表示虛擬函式。用於告訴編譯器不要靜態連結到該函式,改為動態連結。
依然是上面的例子,在 A
的 f
函式上加上 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
中,我們經常會使用 interface
或 abstract
來定義一些介面,方便程式碼規範和擴充,但是在 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
就是一個預處理,用於將其他檔案匯入到一個另一個檔案中,類似 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 DEBUG\n");
#else
// 不參與編譯
printf("No DEBUG\n");
#endif
return 0;
}
// 輸出:I am DEBUG
複製程式碼
以上程式碼,由於先前已經定義了 #define DEBUG
所以 #ifdef DEBUG
為 true
,編譯 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++
的味道,可以實際的去敲一下程式碼來消化這些知識,只有實踐才能出真知。