最近項目急需C++ 的知識結構,雖說我有過快速學習很多新語言的經驗,但對於C++ 老特工我還需保持敬畏(內容太多),本文會從一個Java程序員的角度,制定高效學習路線快速入門C++ 。
Java是為了就業,C++ 是信仰。(C++ 是教學、信仰、商業這三個原本互斥的概念(這三個概念也是三個階段,正好可以陪我們一起成長)的偏偏集合體)
關鍵字:C++ ,基本語法,C++ 與Java對比,環境搭建,helloworld,C++ 工具,C++ 類庫,抽象機制,並發
熱身
基本思想
這一章是高屋建瓴,為學習C++ 定下基調。下面通過斯特魯普(C++發明者)對Java程序員的字字珠璣的建議,再加上我的理解和總結,列出幾點“中心思想”。
- 不要試圖用C++ 來編寫Java程序。
- 不能依賴垃圾收集器了。
- 同為面向對象語言,但要采用C++ 自己的抽象機制【類和模板】。
- 要理解C++ 與C語言是各個方面都不同的程序設計語言(雖然最早C++ 是作為“帶類的C”出現的),不要因為虛假的熟悉感而將代碼寫成C。
- C++ 標准庫很重要很高效,要非常熟悉。
- C++ 程序設計強調富類型、輕量級抽象,希望能細細體會。
- C++ 特別適合資源受限的應用,也是為數不多可以開發出高質量軟件的程序設計語言。
- C++ 的成長速度很快,要與時俱進。
- 一定要有單元測試和錯誤處理模型。
- C++ 將內置操作和內置類型都直接映射到硬件,從而提供高效內存使用和底層操作。
- C++ 有着靈活且低開銷的抽象機制【核心掌握】(可能的話以庫的形式呈現),而不是簡單的如Java一樣上來就給所有類創造一個唯一的基類。
- 盡量不使用引用和指針變量,作為替代,使用局部變量和成員變量。
- 使用限定作用域的資源管理。
- 對象釋放時使用析構函數,而不是模仿finally。:
- 避免使用單純的new和delete,應該使用容器(例如vector,string和map)以及句柄類,(例如lock和unique_ptr)
- 使用獨立函數來最小化耦合,使用命名空間來限制獨立函數的作用域。
- 不要使用異常規范。
- C++ 嵌套類對外圍類沒有訪問權限。
- C++ 提供最小化的運行時反射:dynamic_cast和type_id,應更多依靠編譯時特性。
- 零開銷原則,必須不浪費哪怕一個字節或是一個處理器時鍾周期(C++ 是信仰)
與Java的差別
C++ 是系統程序設計語言(例如驅動程序、通信協議棧、虛擬機、操作系統、標准庫、編程環境等高大上有技術深度的系統),而Java是業務開發語言(例如XXX管理系統,電商網站,微信服務號等基於B/S架構的上層UED相關的應用),高下立判(鄙視鏈是有道理的)。
關於細節的學習
學習C++ 最重要的就是重視基本概念(例如類型安全、資源管理以及不變式)和程序設計技術(例如使用限定作用域的對象進行資源管理以及在算法中使用迭代器),但要注意不要迷失在語言技術性細節中。
學習C++ 一定要避免深入到細節特性中去浪費掉大量時間,
了解最生僻的語言特性或是使用到更多數量的特性並不是什么值得炫耀的事情,學習C++ 細節知識的真正目的是:在良好設計所提供的語境中,有能力組合使用語言特性和庫特性來支持好的程序設計風格。
所以,使用庫來簡化程序設計任務,提高系統質量是非常必要的,學習標准庫是學習C++ 不可分割的一部分。(遇到問題先找庫,這一點我想每個Java程序員骨子里都是這么想的,不會鑽到細節中去。)
領悟編程和設計技術比了解所有細節重要的多。而細節問題不要過分擔心,通過時間的積累,不斷的練習自然就會掌握。
支持庫和工具集
C++ 除了標准庫以外,有大量的標准庫和工具集,現在有數以千計的C++ 庫,跟上所有這些庫的變化是不可能的,因此還是上面那些話,要通過組合使用個語言特性以及庫特性來支持好的程序設計風格,所以熟悉這些庫的領域(不必鑽進去一一研究)以及領悟編程設計技術才是核心點。
C++ 基礎
本章開始正式入門學習C++ ,會從基礎知識、抽象機制、容器和算法、並發以及實用工具這幾個方面進行學習。
基礎知識
C++ 是一種編譯型語言,要想運行一段C++ 程序,需要首先用編譯器把源文件(通常包括許多)編譯為對象文件,然后再用鏈接器把這些對象文件組合生成可執行程序。
C++ 的跨平台體現在源文件的跨平台,而不是可執行文件的跨平台,意思就是根據不同平台(例如Windows、Linux等)的編譯器可以生成支持不同平台的可執行文件。
開發環境
我們這里使用的IDE仍舊是來自jetbrain家族的CLion。
Helloworld
New Project,創建一個新的C++ 項目,CLion會自動為你生成一個HelloWorld的基本項目。這個項目是基於CMake編譯的,如果要連接git庫,我推薦將所有編譯相關的CMake文件都設置為ignore。下面來看一下C++ helloworld代碼main.cpp:
#include <iostream>
int main() {
std::cout << "Hello, World!" << std::endl;
return 0;
}
下面分析一下這段代碼:
- 首行通過“#include
” 指示編譯器把iostream庫include(包含)到本源文件中來。 - int main(),每個C++ 程序中有且只有一個名為main()的全局函數,在執行一個程序時首先執行該函數。
- std::cout,引用自iostream庫的標准輸出流。
- <<,將后面的字符串字面值寫入到前面的標准輸出流中,字符串字面值是一對雙引號當中的字符序列。
編譯執行
CLion采用CMake編譯,需要一個CMakeLists.txt編譯文件。
cmake_minimum_required(VERSION 3.10)
project(banner)
set(CMAKE_CXX_STANDARD 11)
add_executable(banner codeset/simplecal.cpp)
分析一下這個編譯文件:
- 第一行是cmake的最低版本要求
- 第二行指定了項目名稱,可以是別名
- 第三行是指定了編譯版本,這里是C++ 11
- 第四行是加入執行器,需要兩個參數,第一個參數必須是正確的項目名稱,第二個參數是main函數所在位置,也就是執行器入口。
都設置好以后,開始執行,打出正確日志:
/home/liuwenbin/work/CLionProjects/github.com/banner/cmake-build-debug/banner
Hello, World!
Process finished with exit code 0
基本語法
隱藏std
std:: 是用來指定cout所在的命名空間,如果在代碼中涉及大量操作會很麻煩,所以可以通過語法來隱藏掉,我們新建一個cpp源文件(注意默認CLion會直接創建.cpp和.h兩個文件,這是C++ 源文件和頭文件,也可以選擇C的.c和.h。我們這里只保留cpp文件即可,頭文件的使用在后續會應用到,這里可以刪掉),鍵入代碼如下:
//
// Created by liuwenbin on 18-4-14.
//
#include <iostream>
using namespace std;
double square(double x) {
return x * x;
}
void print_square(double x) {
cout << "the square of " << x << " is " << square(x) << "\n";
}
int main() {
print_square(12);
}
在函數print_square中,我們直接使用了cout而沒有像上方一樣std::cout,省略掉了std,執行結果:
the square of 12 is 144
初始化器
int main() {
int a{1}; // 使用初始化器,可以有效避免因為類型轉換而被強制削掉的數據信息。例如int a {1.2}無法通過編譯,而int a=1.2會直接削掉小數部分,a最后等於1
int b = 2.2;
cout << a << " " << b;
}
輸出:
1 2
類型識別
初始化器可以通過=號直接賦值,變量的類型會通過初始化器推斷得到。
int main() {
auto c = 1.2;
auto d = 'x';
auto e = true;
cout << c << " " << d << " " << e;
};
輸出:
1.2 x 1
初始化器列表構造函數
std::initializer_list<double>,使用一個列表進行初始化,下面來看具體使用:
Vector2::Vector2(std::initializer_list<double> lst) : elem{new double[lst.size()]}, sz{lst.size()} {
copy(lst.begin(), lst.end(), elem);
}
↑↓初始化器列表,使用單冒號加空花括號(有點匿名函數的意思)的方式。
TODO: 使用初始化器列表的時候,會報錯 narrowing conversion of XXX。stackoverflow上寫需要在template中定義構造函數,這與當前研究內容走遠了,所以放在后面研究。先不使用初始化器列表。
Vector2::Vector2(std::initializer_list<double> lst) {
elem{new double[lst.size()]}, sz{lst.size()};
copy(lst.begin(), lst.end(), elem);
}
通過一個標准庫的initializer_list
常量
int main() {
const int kim = 38;// const“我承諾這個變量一旦賦值不會再改變”,編譯器負責確認並執行const的承諾
constexpr double max = square(kim); // 編譯時求值,參數必須是const類型,方法也必須是靜態表達式,本行報錯error: call to non-constexpr function
}
標准輸入
上面講了標准輸出是std::cout,那么標准輸入是什么呢?
bool accept() {
cout << "Do you want to accept?(y or n)\n";
char answer = 0;
cin >> answer;
if (answer == 'y')return true;
return false;
}
int main() {
bool a = accept();
cout << a;
}
執行,
Do you want to accept?(y or n)
y(這是我手動輸入的)
1
注意:accept方法必須在main函數的上方,因為cpp源文件編譯是順序的,如果先編譯main函數,就會發生找不到還未編譯的accept方法。
數組
遍歷一個數組:
int main() {
int v[8] = {0, 1, 2, 3, 4, 5, 6, 7};// 越界會報錯
int t[] = {0, 1, 2, 3, 4, 5};// 沒有設定邊界,自動邊界
for (auto i:v) {
cout << "-" << i;
}
}
輸出:
-0-1-2-3-4-5-6-7
這個寫法與其他語言很相似。
指針
指針變量中存放着一個相應類型對象的地址。
引用類似於指針,唯一的區別是我們無須使用前置運算符*訪問所引用的值。換句話說就是引用是直接引用了地址的值,而指針只是指向地址。
引用指的是指針位置的值,指針指的是變量所在的位置,一個變量包括位置(指針)值(引用),賦值時可以修改自身(通過引用),拷貝一份(裸變量名)
一個變量存着值,它的指針是這個值所在的位置,它的引用就是這個位置的這個值本身。
接着來講,一個變量本身存的就是一個變量的位置,那么它的指針就是這個位置內存存儲的值,它的引用就是這個位置的字符串。
結構體
struct Vector {
int a;
double *b;
};
// Vector 初始化方法
void vector_init(Vector &v, int s) {//注意這里要用引用,否則v在后面的操作會在內存中復制一份變量而不是修改引用本身(這與java是不同的)
v.a = s;
v.b = new double[s];// new運算符是從一個自由存儲,又稱作動態內存或堆中分配內存。
}
// Vector 應用:從cin中讀取s個整數,返回Vector
Vector read_and_sum(int s) {
Vector v;
vector_init(v, s);
for (int i = 0; i != s; ++i) {
cin >> v.b[i];
}
return v;
}
輸出:
1(手動輸入)
2(手動輸入)
3(手動輸入)
3 0x18bec20
Vector是一個struct,所有成員並沒有任何要求,都是公開的,相當於一個不加限制的任意類型。
類
上面的自定義類型的結構體特性很好,但是有時候我們希望能夠對內部數據進行訪問限制,例如外部不允許直接訪問Vector的屬性a,那么類結構是很好的解決方式。
class Vector2 {// 在源文件中定義一個類
public: //公開的方法,通過方法與屬性進行交互
Vector2(int s) : elem{new double[s]}, sz{s} {}//定義了一個構造函數,通過匿名內部類的形式
double &operator[](int i) { return elem[i]; }//自定義運算符“[]”,根據下標獲取elem對應的元素值
int size() { return sz; }//獲取elem的大小
private: //不可以直接訪問屬性
double *elem;
int sz;
};
Vector2是一個類,它包含兩個成員,一個是elem的指針,一個是整型數據,所以Vector2的對象其實是一個“句柄”,並且它本身的大小永遠保持不變,因為成員中一個固定大小的句柄指向“別處”內存的一個位置,無論這個位置通過new在自由存儲中分配了多少空間,對於Vector2對象來說,它只存儲一個句柄,這個數據的大小可以穩定的。
不變式
以上struct和類的最大區別就在於類的不變式,與自由的struct不同的是,類的不變式約定了類成員數據的一種限制條件,它是類合理性的體現,類承擔了維護不變式的責任。如果每個數據成員都可以被賦以任何值,那么它就不是類,只是個結構,使用struct就好了。
注意Java程序員的惡習,如果一個類的所有成員都是私有的,然后它提供了或僅提供了這些成員的get,set方法,這在C++ 中是沒意義的,直接使用struct吧。
枚舉
enum class Color {// 作用域
red, blue, green
};
enum class traffic_light {
green, yellow, red
};
traffic_light &operator++(traffic_light &t) {// 枚舉屬於自定義類型,那么也可以自定義運算符++
switch (t) {
case traffic_light::green:
return t = traffic_light::yellow;
case traffic_light::yellow:
return t = traffic_light::red;
case traffic_light::red:
return t = traffic_light::green;
}
}
int main() {
Color col = Color::red;
traffic_light light = traffic_light::red;
traffic_light a = ++light;
}
枚舉類型是自定義類型,它不是基本類型,red是它的一個對象,它的運算需要通過自定義運算符操作。
模塊化
我們在寫以上內容的時候,其實一直都有一種困擾:如何在函數、用戶自定義類型、類以及模板之間進行交互?或者說復用?
分離編譯
用戶代碼只能看見所用類型和函數的聲明,它們的定義則放置在分離的源文件里,並被分別編譯。這個結構是:
頭文件定義接口,相同名稱的cpp文件進行實現,然后其他cpp文件使用的時候引入頭文件即可。
注意:雖然cpp文件實現頭文件接口的機制與java很像,但C++ 是非常靈活的語言,它沒有固定范式,所以一定要保持警惕,這並不是頭文件唯一能做的事情,接口和所謂實現也不像java那樣嚴格要求。
我們可以把上面對類Vector2的聲明定義放到一個頭文件Vector2.h中去。用戶需要將該頭文件include進程序才可訪問接口。
Vector2.h
class Vector2 {// 頭文件中只放置接口的描述聲明,不寫實現(相當於Java中的一個接口)
public: //公開的方法,通過方法與屬性進行交互
Vector2(int s);
double &operator[](int i);
int size();
private://不可以直接訪問屬性
double *elem;
int sz;
};
Vector2.cpp
#include "Vector2.h"//頭文件聲明(接口),cpp文件實現,名稱要一致。
Vector2::Vector2(int s)
: elem{new double[s]}, sz{s} {
}
double &Vector2::operator[](int i) {
return elem[i];
}
int Vector2::size() {//Vector2::命名空間的語法
return sz;
}
user.cpp
//
// Created by liuwenbin on 18-4-16.
//
#include "Vector2.h"
#include <cmath>
#include <iostream>
using namespace std;
double sqrt_sum(Vector2 &v) {
double sum = 0;
for (int i = 0; i != v.size(); ++i) {
sum += sqrt(v[i]);
}
return sum;
}
// CMakeLists.txt中add_executable只要加入實現類即可,換句話說不必加入.h文件
int main() {
Vector2 v(8);
v[0] = 1;
v[2] = 1;
v[10] = 1;//沒有越界處理(見下方《錯誤處理》)
cout << sqrt_sum(v);
}
注意,CMakeLists.txt要修改,
add_executable(banner codeset/user.cpp codeset/Vector2.cpp)
配置完成以后,運行user.cpp的main函數,輸出為:2
命名空間 namespace
作用:
- 表達某些聲明是屬於一個整體的
- 表明他們的名字不會與其他命名空間中的名字沖突
namespace Mine {
class complex {
};
complex sqrt(complex);
int main();
}
int Mine::main() {
//complex z{1, 2};// 由於沒有實現complex類,所以這部分初始化器會靜態報錯。
auto z2 = sqrt(z);
}
int main() { // 全局命名空間,真正的main函數,執行器入口
return Mine::main();// 調用命名空間Mine下的main函數。
}
上面Vector2的命名空間的語法我們介紹了,這里再次加深理解命名空間的含義。
上面代碼中也經常出現了,要想獲取標准庫的命名空間中的內容訪問權,要使用using。
using namespace std;
命名空間主要用於組織較大規模的程序組件(架構經常使用),最典型的例子是庫。使用命名空間,我們就可以很容易地把若干獨立開發的部件組織成一個程序。
一個程序的組織包括:命名空間+執行器(要包含所有相關源文件cpp即可,以及基於全局命名空間的main入口函數)
錯誤處理
通常的應用程序在構建時,大部分都要依靠新類型(例如string,map和regex)以及算法(例如sort(),find_if()和draw_all()),這些高層次的結構簡化了程序設計,減少了產生錯誤的機會。C++ 的設計思想一定是建立在優雅高效的抽象機制。模塊化、抽象機制、庫、命名空間都是C++ 程序架構的體現。
上面我們留下了一個錨,關於數組越界的問題,下面我們寫一個錯誤處理。
首先加到自定義運算符[]的函數內,加入錯誤判斷,並且拋出異常
double &Vector2::operator[](int i) {
if (i >= size())throw std::out_of_range("Vector2::operator[]");// std是標准庫的意思,上面包含了<stdexcept>庫,這里統一使用std作為命名空間。
return elem[i];
}
然后在使用該運算符的位置,利用try catch對來捕捉異常並做出異常處理
int main() {
Vector2 v(8);
v[0] = 1;
v[2] = 1;
try {
v[10] = 1;//沒有越界處理(不是工程代碼,還有很多待完善)
} catch (out_of_range) {
cout << "out_of_range error";
return 0;// 跳出程序
}
cout << sqrt_sum(v);
}
輸出:
out_of_range error
從上面可以總結出,錯誤處理分三步:
- 錯誤判斷
- 拋異常
- 錯誤處理
上面的錯誤判斷以及拋異常放在類的構造函數中就是類的不定式的概念,用於檢查構造函數傳入的實參是否有效。
編譯時錯誤檢查:靜態斷言
int main() {
Vector2 v(4000);// 傳入的整數為double數組的大小,但是由於Vector2中存儲的只是“句柄”,這在上面已經提過了,Vector2對象的大小是永遠不變的,是16。
cout << sizeof(v);
static_assert(4 <= sizeof(v), "size is too small!");
}
輸出為16,當我們將靜態斷言的判斷條件改為32時,執行以后報錯,報錯日志截取一部分:
/home/liuwenbin/work/CLionProjects/github.com/banner/codeset/user.cpp:32:5: error: static assertion failed: size is too small!
static_assert(32 <= sizeof(v), "size is too small!");
^
sizeof() 返回的是實參所占的字節數,例如一個char是1個字節,即sizeof(char)=1,整型int是4個字節,double是8個字節。
注意:靜態斷言的前置條件必須是與一個常量進行比較,比如上面就是與4還有32進行比較,如果是變量來代替確定數字的話,那么該變量必須是const類型的,不可改變的,否則會報錯。
抽象機制
上面反復提到了C++ 的高效優雅的抽象機制。本章將重點介紹這部分內容,主要包括類和模板。
類
類包含具體類,抽象類,類層次(暫理解為繼承實現等)中的類。
具體類型
具體類型的成員變量就是表現形式的概念
成員變量可以是一個或幾個指向保存在別處的數據的指針(例如上面的Vector2 elem成員的定義是double *elem),這種成員變量也會存在於具體類的每一個對象中。
通過使用類的成員變量,它允許我們:
- 把具體類型的對象至於棧、靜態分配的內存或者其他對象中。
- 直接引用對象(而非僅僅通過指針或引用)
- 創建對象后立即進行完整的初始化
- 拷貝對象
類的成員變量可以被限定為private,只能通過public的成員函數訪問。
成員變量一旦發生任何改變都要重新編譯,如果想提高靈活性,具體類型可以將其成員變量的主要部分放置在自由存儲(動態內存、堆)中,然后通過存儲在類對象內部的另一部分訪問他們。
一個完整的例子,實現復數complex(簡單)
#include <iostream>
namespace Mine {
class complex {
double re, im;//復數包含兩個雙精度浮點數。一個是實部,一個是虛部
public:
//定義三個構造函數,分別是兩個實參、一個實參以及無參
complex(double r, double i) : re{r}, im{i} {
}
complex(double r) : re{r}, im{0} {
}
complex() : re{0}, im{0} {// 無參的構造函數是默認構造函數
}
// getter setter
double real() const {// 返回實部的值,const標識這個函數不會修改所調用的對象。
return re;
}
void real(double d) {// 設置實部的值
re = d;
}
double imag() const {// 返回虛部的值,const標識這個函數不會修改所調用的對象。
return im;
}
void imag(double d) {// 設置虛部的值
im = d;
}
// 定義運算符
complex &operator+(complex z) {
re += z.re;
im += z.im;
return *this;
}
complex &operator-(complex z) {
re -= z.re;
im -= z.im;
return *this;
}
// 接口,只描述方法,實現在外部的某處進行。
complex &operator*=(complex);
complex &operator/=(complex);
};
complex test();
}
Mine::complex Mine::test() {
complex z1{1, 2};
complex z2{3, 4};
return z1 + z2;
}
using namespace std;
int main() {
//complex a{1,2}; // 靜態報錯,這里complex的作用域是全局,而不是上面Mine中定義的那個。
cout << Mine::test().imag();
}
輸出:
6
析構函數
上面我們定義的Vector2類,有一個致命缺陷(java程序員可能意識不到)就是它使用了new分配元素但卻沒有釋放這些元素的機制。這是個糟糕的設計,所以這一節我們要引入析構函數來保證構造函數分配的內存一定會被銷毀。
我們在Vector2.h頭文件的類聲明中:
// 加入析構函數
~Vector2() {
delete[] elem;
}
在容器類Vector2加入析構函數以后,外部的使用者無需干預,就想使用普通內置變量那樣使用Vector2即可,而Vector2對象會在作用域結束處(例如右花括號)自動delete銷毀對象。
幾個概念。
數據句柄模型:構造函數負責分配元素空間以及初始化成員,析構函數負責釋放空間,這種模型被稱作數據句柄模型。
RAII,在構造函數中請求資源,析構函數釋放資源,這種技術被稱作資源獲取即初始化,英文Resource Acquisition Is Initialization,簡稱RAII。
所以我們的程序設計一定要基於數據句柄模型,采用RAII技術,換句話來說,就是避免在普通代碼中分配內存或釋放內存,而是要把分配和釋放隱藏在好的抽象的實現內部。
抽象類型
抽象類可以做真正的接口類,因為它分離接口和實現並且放棄了純局部變量。
class Container {
public:
virtual double &operator[](int) = 0;//純虛函數,
virtual int size() const = 0;// 常量成員
virtual ~Container() {};//析構函數
};
幾個概念。
- 虛函數:有關鍵字virtual的函數被稱為虛函數。
- 純虛函數:虛函數還等於0的被稱為純虛函數。
- 抽象類:存在純虛函數的類被稱為抽象類。
使用Container,
void use(Container &c) {// 方法體內部完全使用了Container的方法,但是要知道目前這些方法還沒有類來實現。
const int sz = c.size();
for (int i = 0; i != sz; i++) {
std::cout << c[i] << '\n';
}
}
如果一個類專門為了其他一些類來定義接口,那么我們把這個類稱為多態類型,所以Container類是多態類型。
下面寫一個實現類Vector3.cpp
#include "Container.h"
#include "Vector2.h"
class Vector_container : public Container {// 派生自(derived)Container,或者實現了Container接口
Vector2 v;
public:
Vector_container(int s) : v(s) {}
~Vector_container() {}
double &operator[](int i) {
return v[i];
}
int size() const {
return v.size();
}
};
幾個概念,其實和其他OO語言差不多,
- Vector_container是Container的子類或派生類
- Container是Vector_container的基類或超類。
- 他們的關系就是繼承。
虛函數
我們首先對Vector2.h頭文件進行改造:
#include <initializer_list>
#include <algorithm>
#include <stdexcept>
class Vector2 {// 頭文件中只放置類相關內容,復雜成員方法可不實現,但它與完全的抽象類作為多態類型的接口不同
private://不可以直接訪問屬性
double *elem;
int sz;
public: //公開的方法,通過方法與屬性進行交互
Vector2(int s) : elem{new double[s]}, sz{s} {// 構造函數的實現可以寫在頭文件中,屬於簡單公用方法
}
// Vector2(std::initializer_list<double> lst) : elem{new double[lst.size()]},
// sz{lst.size()} {// 構造函數的實現可以寫在頭文件中,屬於簡單公用方法
// std::copy(lst.begin(), lst.end(), elem);
// }
double &operator[](int i) {
if (i >= size())throw std::out_of_range("Vector2::operator[]");// std是標准庫的意思,上面包含了<stdexcept>庫,這里統一使用std作為命名空間。
return elem[i];
}
int size() const {
return sz;
}
~Vector2() {// 加入析構函數,有實現,屬於公用方法,實現方式都是一樣的。
delete[] elem;
}
};
因為本章學習的方向,我們將Vector2.h頭文件中對Vector2類的成員方法全部實現了。而沒有使用Vector2.cpp,
總結一點:一般來講永遠都是在程序中引入別的類的頭文件進行使用,而沒有引用cpp文件的,,這一節知識與Vector2.cpp無關,因此這里我們對Vector2.h頭文件進行豐富的道理也在這。
Vector2.h中構造函數——初始化器列表被注釋掉,原因在上面的《初始化器列表》小節中有專門講述。
然后,重新寫Vector3.cpp,
//
// Created by liuwenbin on 18-4-16.
//
#include "Container.h"
#include "Vector2.h"
#include <list>
/**
* Container接口有兩個實現類:Vector_container以及List_container
*/
// Vector_container類的定義
class Vector_container : public Container {// 派生自(derived)Container,或者實現了Container接口
Vector2 v;
public: // 成員方法都重用了Vector2的具體實現方法。
Vector_container(int s) : v(s) {}
~Vector_container() {}// 覆蓋了基類的析構函數~Container()
double &operator[](int i) {
return v[i];
}
int size() const {
return v.size();
}
};
// List_container類的定義
class List_container : public Container {
std::list<double> ld;// 與Vector_container的成員是我們自定義的Vector2不同的是,這里的成員是采用的標准庫的list。
public:
List_container() {}
// 由於標准庫的list的初始化器列表實現更加高可用,所以這里可以采用初始化器列表,更加方便
List_container(std::initializer_list<double> il) : ld{il} {}
~List_container() {}
double &operator[](int i);// 沒有花括號的方法體,說明這個方法在類聲明期間並沒有實現
int size() const { return ld.size(); }
};
// 實現操作符[]
double &List_container::operator[](int i) {
for (auto &x:ld) {
if (i == 0)return x;
--i;
}
throw std::out_of_range("List container");
}
// 接收Container接口類型對象為實參,不考慮其實現類的實現細節的通用方法。
void use(Container &c) {// 方法體內部完全使用了Container的方法,但是要知道目前這些方法還沒有類來實現。
const int sz = c.size();
for (int i = 0; i != sz; i++) {
std::cout << c[i] << '\n';
}
}
void g() {
// Vector_container vc{1, 2, 3, 4, 5, 6};
Vector_container vc(3);// 使用了Vector_container
vc[1] = 1;
vc[2] = 3;
use(vc);
}
void h() {
List_container lc = {1, 2, 3};// 使用了List_container,采用初始化器列表的方式構造函數,十分方便。
use(lc);
}
// 入口函數,分別調用以上方法測試。
int main() {
g();
h();
}
輸出:
0
1
3
1
2
3
具體實現細節請看代碼注釋,這里不再贅述。下面總結幾點心得:
- 我們要完成可與標准庫的list媲美的自定義類型(例如Vector2),需要很多工作要做。
- 注意保持類定義的簡潔性,可將復雜抑或有個性化可能的方法實現留給派生方法去做,而在類定義中只保留公用唯一簡單方法的實現。
- 派生類的很多成員方法都可以通過成員變量(例如list)的內部方法來實現。
- use方法中可以根據傳入的不同Container的實現類的真實對象,來調用真實對象本身的實現方法,這是基於一個虛函數表(vtbl),每個含有虛函數的類都有它自己的vtbl用於辨識虛函數。
- 類的虛函數(接口方法)的空間開銷包括:一個額外的指針,每個類都需要一個vtbl。
類層次
類層次就是通過派生創建的一組類,在框架中有序排列,比如上面的Vector3.cpp源文件中的Container基類與Vector_container以及List_container組成的一組類就形成了類層次。類層次共有兩種便利:
- 接口繼承,派生類對象可以用在任何需要基類對象的地方。也就是說,基類看起來像是派生類的接口一樣。Container就是這樣的一個例子。
- 實現繼承,基類負責提供可簡化派生類實現的函數和數據(例如成員屬性以及已實現的構造函數)。
類層次中的成員數據有所區別,我們傾向於通過new在自由存儲中為其分配空間,然后通過指針或引用訪問它們。
函數返回一個指向自由存儲上的對象的指針是非常危險的,因為該對象很可能消失,這個指針參與的工作就會發生錯誤。
千萬不要濫用類繼承體系,如果兩個類沒有任何關系(例如工具類),那么將他們獨立開來,我們在使用的時候可以自由組合,而不必因為共同繼承或實現了一個基類而焦頭爛額。
拷貝和移動
當我們設計一個類時,必須仔細考慮對象是否會被拷貝以及如何拷貝的問題。
逐成員的復制,意思就是遍歷類的成員按順序復制的方法。這種方法在簡單的具體類型中會更符合拷貝操作的本來語義。但是在復雜具體類型以及抽象類型中,逐成員復制常常是不正確的。
原因是涉及得到指針的成員的類,在拷貝操作中,很可能復制出來的只是對真實數據的指針或引用,而並沒有對真實數據進行拷貝一份副本。這就是問題所在。
拷貝容器
資源句柄(resource handle),當一個類負責通過指針訪問一個對象時,這個類就是作為資源句柄的存在。
我們在Vector2.h頭文件中先聲明一個執行拷貝操作的構造函數
Vector2(const Vector2 &a);// 拷貝操作
然后在Vector3.cpp中實現以上操作:
// 實現Vector2.h頭文件中拷貝操作
// 先用初始化器按照實參原對象的大小將內存空間分配出來。
Vector2::Vector2(const Vector2 &a) : elem{new double[sz]}, sz{a.sz} {
// 執行數據的復制操作
for (int i = 0; i != sz; i++)
elem[i] = a.elem[i];
}
移動容器
移動構造函數,執行從函數中移出返回值的任務。我們繼續在Vector2.h頭文件中先聲明一個執行移動操作的構造函數
Vector2(Vector2 &&a);// 移動操作
然后在Vector3.cpp中實現以上操作:
// 實現Vector2.h頭文件中的移動構造函數
// 先用初始化器按照實參原對象的大小將數據移到新對象中。
Vector2::Vector2(Vector2 &&a) : elem{a.elem}, sz{a.sz} {
// 清除a的數據
a.elem = nullptr;
a.sz = 0;
}
&:引用
&&:右值引用,我們可以給該引用綁定一個右值,大致意思是我們無法為其賦值的值,比如函數調用返回一個整數。
換句話講,右值引用的含義就是引用了一個別人無法賦值的內容。
幾點注意:
- 該移動構造函數不接受const實參,畢竟移動構造函數最終要刪除掉它實參的值。
- 我們也可以根據這個思想構建移動賦值運算符。
- 移動操作完成以后,源對象所進入的狀態應該能允許運行析構函數。通常,我們也應該允許為一個移動操作后的源對象賦值。
以值的方式返回容器(依賴於移動操作以提高效率)。
資源管理
通過以上的構造函數、拷貝操作、移動操作以及析構函數,程序員就能對受控資源的全生命周期進行管理。例如標准庫的thread和含有百萬個double的Vector,前者不能執行拷貝操作,后者我們不希望拷貝它(成本太高)。在很多情況下,用資源句柄比用指針效果好,就像替換掉程序中的new和delete一樣,我們也可以把指針轉化為資源句柄。在這兩種情況下,都將得到簡單也更易維護的代碼,而且沒有額外的開銷。特別是我們能實現強資源安全(strong resource safety)不要泄露任何你認為是資源的東西,換句話說,對於一般概念上的資源,這種方法都可以消除資源泄露。
抑制操作
對於層次類來講,使用默認的拷貝和移動構造函數都意味着風險:因為只給出一個基類的指針,我們無法了解派生類有什么樣的成員,當然也不知道該如何操作他們。因此,最好的做法是刪除掉默認的拷貝和移動操作,也就是說,我們應該盡量避免使用這兩個操作的默認定義。
模板
一個模板就是一個類或一個函數,但需要我們用一組類型或值對其進行參數化。我們使用模板表示那些通用的概念,然后通過指定實參(比如指定元素的類型為double)生成特定的類型或函數。(C++ 中一個高大上的知識)
參數化類型
#include <initializer_list>
#include <algorithm>
#include <stdexcept>
template<typename T>
class VecTemp {// 頭文件中只放置類相關內容,復雜成員方法可不實現,但它與完全的抽象類作為多態類型的接口不同
private://不可以直接訪問屬性
T *elem;
int sz;
public: //公開的方法,通過方法與屬性進行交互
VecTemp(int s) : elem{new T[s]}, sz{s} {// 構造函數的實現可以寫在頭文件中,屬於簡單公用方法
}
double &operator[](int i) {
if (i >= size())throw std::out_of_range("VecTemp::operator[]");// std是標准庫的意思,上面包含了<stdexcept>庫,這里統一使用std作為命名空間。
return elem[i];
}
int size() const {
return sz;
}
~VecTemp() {// 加入析構函數,有實現,屬於公用方法,實現方式都是一樣的。
delete[] elem;
}
VecTemp(const VecTemp &a);// 拷貝操作
VecTemp(VecTemp &&a);// 移動構造函數
};
我新建一個類VecTemp,將Vector2的內容復制了進來,同時修改了所有類名為統一的VecTemp,接着在類聲明之上加入了template關鍵字,同時加入以單尖括號的形式的typename T,最后修改類代碼中的所有具體類型的double為T。我們對Vector2的類型參數化改造就完成了,這就是一個模板,我們在外部不僅可以傳入double類型的,任何其他內置類型甚至自定義類型都可被支持。這個理念與java中的泛型是一致的,感興趣的朋友可以參考一下我的另一篇博文《大師的小玩具——泛型精解》
使用容器保存同類型值的集合,將其定義為資源管理模板。
函數模板
上面我們用T來泛型了所有的數據類型,下面我們也可以使用基類或者超類來定義整個其派生類均適用的函數。
template<typename Container, typename Value>
Value sum(const Container &c, Value v) {
for (auto x:c)
v += x;
return v;
};
以上這個程序可以計算任意容器中的元素的和。
使用函數模板來表示通用的算法。
函數對象
模板的一個特殊用途是函數對象,有時也稱為函子functor。我們可以像調用函數一樣調用對象。下面定義一個模板,可以自動比較大小
template<typename T>
class Less_than {
const T val;
public:
Less_than(const T &v) : val(v) {}
bool operator()(const T &x) const {
return x < val;
}
};
下面是該模板的使用:
int main() {
Less_than<int> lti{19};
Less_than<std::string> lts{"hello"};
std::cout << lti(2);
std::cout << lti(50);
std::cout << lts("world");
}
輸出:100
說明2比19小為true,輸出1,50比19小為flase,輸出0。
C++ 的布爾值true為1,false為0。
函數對象val,精妙之處在於他們隨身攜帶着准備與之比較的值,我們無須為每個值(或每種類型)單獨編寫函數,更不必把值保存在讓人厭倦的全局變量中。同時,像Less_than這樣的簡單函數對象很容易內聯,因此調用Less_than比間接調用更有效率。正是因為函數對象具有可攜帶數據和高效這兩個特性,我們經常用其作為算法的實參。
lambda表達式
[&](int a){return a<x;} 這種語法被稱為Lambda表達式,它生成一個函數對象,就像less_than
- 如果我們希望只“捕獲”x,則可以寫成[&x];
- 如果希望給生成的函數對象傳遞一個x的拷貝,則寫成[=x];
- 什么也不捕獲,是[];
- 捕獲所有通過引用訪問的局部名字是[&];
- 捕獲所有以值訪問的局部名字是[=];
使用Lambda雖然簡單便捷,但也有可能稍顯晦澀難懂。對於復雜的操作(不是簡單的一條表達式),我們更願意給該操作起個名字,以便更加清晰地表述他們的目的並且在程序中隨處使用它。
使用函數對象表示策略和動作。
可變參數模板
定義模板時可以令其接受任意數量任意類型的實參,這樣的模板稱為可變參數模板。
template<typename T, typename... Tail>
void f(T head, Tail... tail) {
//對head做事
f(tail...);
};
void f() {}
省略號... 表示列表的“剩余部分”
別名
很多情況下,我們應該為類型或模板引入一個同義詞,例如標准庫頭文件<cstddef>包含別名size_t的定義:
using size_t = unsigned int;
其中,size_t的實際類型依賴於具體實現,使用size_t,程序員可以寫出易於移植的代碼。
template<typename Key, typename Value>
class Map {
//...
};
template<typename Value>
using String_map = Map<std::string, Value>;// 使用別名String_map
String_map<int> m;//m是一個Map<string,int>
事實上,每個標准庫容器都提供了value_type作為其值類型的名字(別名),這樣我們編寫的代碼就能在任何一個服從這種規范的容器上工作了。
使用類型別名和模板別名為相似類型或可能在實現中變化的類型提供統一的符號。
容器與算法
字符串
//
// Created by liuwenbin on 18-4-18.
//
#include <string>
#include <iostream>
using namespace std;
string name = "Tracy Mcgrady";
void m3() {
string s = name.substr(6, 7);
cout << s << "\n";
cout << name << "\n";
name.replace(0, 5, "Hashs");
cout << name << "\n";
name[0] = tolower(name[0]);
cout << name << "\n";
};
int main() {
m3();
}
輸出:
Mcgrady
Tracy Mcgrady
Hashs Mcgrady
hashs Mcgrady
以上代碼練習了簡單的字符串相關的操作。
IO流
輸入
istream庫,cin關鍵字。上面介紹過。iostream具有類型敏感、類型安全和可擴展等特點。
輸出
ostream庫,一般來講,我們會直接引入iostream,輸入輸出都包含了,省事。cout關鍵字,上面介紹過。
自定義IO
//
// Created by liuwenbin on 18-4-18.
//
#include <string>
#include <iostream>
using namespace std;
struct Entry {
string name;
int number;
};
// 輸出比較好實現,就是相當於拼串
ostream &operator<<(ostream &os, const Entry &e) {
return os << "{\"" << e.name << "\"," << e.number << "}";
}
//
//// 輸入要檢查很多格式,所以比較復雜
//istream &operator>>(istream &is, Entry &e) {
// char c, c2;
// if (is >> c && c == '{') {// 以一個{開始,
// string name;// 收集name的信息
// while (is.get(c) && c != ',') {
// name += c;
// }
// if (is >> c && c == ',') {// 以,間隔,開始收集number的信息
// int number = 0;
// if (is >> number >> c && c == '}') {// 直到遇到}結束
// e = {name, number};
// return is;
// }
// }
// }
// is.setf((ios_base::fmtflags) ios_base::failbit);
// return is;
//}
int main() {
Entry ee{"John Holdwhere", 3421};
cout << ee << "\n";
}
輸出:
{"John Holdwhere",3421}
我們定義了一個結構Entry,格式是{"John Holdwhere",3421}這種。然后我們定義了輸出操作符<<,內部實現就是針對Entry的兩個元素進行拼串(相當於Java中的toString())。重寫輸入操作符有點問題,這里不展開討論了。
容器
如果一個類的主要目的是保存一些對象,那么我們統一稱之為容器(注意與普通類以及結構區分)。注意,上面講到的模板泛型T[]數組,不如使用vector<T>,map<K,T>,unordered_map<K,T>。
vector
一個vector就是一個給定類型元素的序列(vector是帶邊界檢查的,可以自動處理越界問題),元素在內存中是連續存儲的。我們在上面已經實現了基於泛型的vector<T>容器,該容器可以存儲不同類型的對象的集合。以后可以直接使用標准庫的vector即可。
vector<int> v1{1, 2, 3, 4};
vector<Entry> en2{{"John Holdwhere", 3421},
{"John Holdwhere", 3421},
{"John Holdwhere", 3421}};
cout << v1[2];
cout << en2[2].number;
輸出:33421
list
我們還是直接使用標准庫的list,這是一個雙向鏈表。
如果我們希望在一個序列中添加和刪除元素的同時無須移動其他元素,則應該使用list。換句話說,對於有大量添加刪除操作的需求,采用list容器比較合適。
list<int> i2{1, 2, 3, 4};
//list<int>::iterator p; //會中止SIGSEGV,不報錯
//i2.insert(p, 2);
for (auto x:i2)
cout << x;
TODO: SIGSEGV中止信號
上面的例子都可以用vector來代替,除非你有更充分的理由,否則就應該使用vector。vector無論是遍歷(如find()和count())性能還是排序和搜索(如sort()和binary_search())性能都優於list。
map
當出現大量特定結構{Key,Value}的數據時,我們希望通過Key來查找Value,以上容器都是很低效的實現。因此有了map,它通過key來高速查找value是基於搜索樹(紅黑樹)【Knowledge_SPA——精研查找算法】。map有時也被稱為關聯數組或字典,map通常用平衡二叉樹來實現。
map<string, int> m1{{"John Holdwhere", 3421},
{"AKA", 991},
{"FYke", 0110}};
cout << m1.size() << endl;
cout << m1.at("AKA") << endl;
cout << m1["FYke"] << endl;
輸出:
3
991
72
map的應用與其他語言差不多,注意要使用標准庫的而不是自己實現一套即可。
unordered_map
map的搜索時間復雜度是O(log(n)),它是必須遍歷一遍所有的元素才能找到指定Key的值。試想如果樣本擴大到100萬,我們只想20次通過比較或者間接尋址的方式查出需要的元素,這就是基於哈希查找,(惡補一下吧【Knowledge_SPA——精研查找算法】),而不是通過比較操作。標准庫的unordered_map容器就是無序容器。它的操作與map基本一致,但根據特定場景,性能突出很明顯。
TODO: unordered_map使用場景,性能測試。
以上介紹了一些常見容器,除此之外,標准庫還有deque<T>,set<T>,multiset<T>,multimap<K,V>,unordered_multimap<K,V>,unordered_set<K,V>,unordered_multiset<T>。注意所有帶unordered的都是無序容器,他們都針對搜索進行了優化,是通過哈希表來實現的。
一些針對所有容器的基本操作:
- begin(),end()獲取首位元素和尾元素
- push_back()可用來高效地向vector、list及其他容器的末尾添加元素。
- size()返回元素數目。
- 下標操作,get元素,at等待
最后總結,推薦標准庫vector作為存儲元素序列的默認類型,只有當你的理由足夠充分時,再考慮其他容器。
算法
針對容器的操作,除了上面列舉的一些簡單操作,還會有排序、打印、抽取子集、刪除元素以及搜索等更復雜的操作,因此,標准庫除了提供容器以外,還為這些容器提供了算法。
迭代器
標准庫算法find在一個序列中查找一個值,返回的結果是指向找到的元素的迭代器。
bool has_c(const string &s, char c) {
auto p = find(s.begin(), s.end(), c);
if (p != s.end()) {// 如果找不到,返回的是end();
return true;
} else {
return false;
}
}
調用以上函數
string name = "YANSUI";
cout << has_c(name, 'Y') << endl;
cout << has_c(name, 'O') << endl;
輸出:
1
0
包含Y為true輸出1,不包含O為false,輸出0。has_c函數的短版寫法:
bool has_c(const string &s, char c) { return find(s.begin(), s.end(), c) != s.end();}
下面在字符串中查找一個字符出現的所有位置。我們返回一個string迭代器的vector。
vector<string::iterator> find_all(string &s, char c) {
vector<string::iterator> res;
for (auto p = s.begin(); p != s.end(); ++p) {
if (*p == c) {// 找到位置相同的元素了
res.push_back(p);
}
}
return res;
}
void findall_test() {
string m{"Mary had a little lamb"};
for (auto p:find_all(m, 'a')) {
if (*p != 'x') {
cerr << "a bug" << endl;// 如果是'a bug'會自動轉為char,是個很大的整數值。
}
}
}
int main() {
string name = "YANSUYI";
// cout << has_c(name, 'Y') << endl;
// cout << has_c(name, 'O') << endl;
for (auto p:find_all(name, 'Y')) {
cout << &p << endl;
}
// cout << find_all(name, 'O').size() << endl;
findall_test();
}
輸出:
a bug
0x7ffd98991f90
a bug
0x7ffd98991f90
a bug
a bug
以上算法都可以引入模板(即泛型)設計成不計數據特定類型的通用方法。
注意:上面的main函數中的迭代器遍歷輸出時,我們改成這樣:
cout << *p << endl;//cout標准輸出默認是通過<< 傳入一個值的位置(指針),然后輸出這個值的內容。
cout << &p << endl;//這里的p是一個對象,它本身是存着一個值的位置,使用引用以后,打印的是這個位置本身的值。
輸出結果為:
Y
0x7ffec22899f0
Y
0x7ffec22899f0
引用是變量位置本身的值,指針是變量位置。標准輸出是通過運算符<<傳入一個位置,輸出它的值。由於p本身是位置,所以輸出p的引用就直接打印除了內存位置字符串,而輸出p的指針就是打印出來這個位置存的值。
predicate
查找滿足特定要求的元素的問題,可以通過predicate方法來解決。
struct Greater_than {
int val;
Greater_than(int v) : val{v} {};
// 通過pair來配對map的搜索,pair是專門用來做predicate操作而存在的。
bool operator()(const pair<string, int> &r) { return r.second > val; };
};
void f(map<string, int> &m) {
auto p = find_if(m.begin(), m.end(), Greater_than{42});
//...
}
神奇的Lambda來寫是這樣:
map<string, int> m1{{"YANSUI", 55},
{"AKA", 991},
{"FYke", 0110}};
int cxx = count_if(m1.begin(), m1.end(), [](const pair<string, int> &r) { return r.second > 42; });// Lambda表達式,完美替代以上Greater_than和void f()方法。
cout << cxx;
輸出:3
容器算法
算法的一般性定義:
算法就是一個對元素序列進行操作的函數模板。
標准庫提供了很多算法,它們都在頭文件<algorithm>中且屬於命名空間std。下面與容器一樣,我們列舉一下其他常見的算法:find,find_if,count,count_if,replace,replace_if,copy,copy_if,unique_copy,sort,equal_range,merge。以后慢慢熟悉。
排序算法
vector<int> v23{122, 8, 42};
sort(v23.begin(), v23.end());
for (auto &x:v23) {
cout << x << endl;
}
輸出:
8
42
122
並發
再次重申標准庫關注的是所有需求的交集而不是並集。此外,標准庫也在一些特別重要的應用領域(如數學計算和文本操作等)提供支持。
並發:
- 提高吞吐率(用多個處理器共同完成單個運算)
- 提高相應速度(允許程序的一部分在等待響應時,另一部分繼續執行)
C++提供了一個適合的內存模型和一套原子操作用來支持並發。下面要提及的有thread,mutex,lock(),packaged_task,future,這些特性直接建立在操作系統並發機制之上,與系統原始機制相比,這些特性並不會帶來額外的性能開銷,當然也不保證性能有顯著提升(巧婦難為無米之炊)。
資源管理
資源是指程序中符合先獲取后釋放(顯式或隱式)規律的東西,比如內存、鎖、套接字、線程句柄和文件句柄等。
資源管理就是對以上資源的及時釋放的處理。這部分內容我們在容器章節已有所領悟,一個標准庫的組件不會出現資源泄露的問題,因為設計者使用成對的構造函數和析構函數等基本語言特性來管理資源,確保資源依存於其所屬的對象,而不會超過對象的生命周期。這里再次提及RAII(使用資源句柄管理資源)。
智能指針:unique_ptr與shared_ptr
- unique_ptr:對應所有權唯一的情況,用它來訪問多態類型對象(可以直接垃圾管理到原始位置,不會造成資源泄露的情況)。
- shared_ptr:對應所有權共享的情況。
這些智能指針最基本的作用是防止由於編程疏忽而造成的內存泄露。
void f(int i, int j) {
vector<int> *p = new vector<int>;// new是分配內存空間,所以要用指針類型變量來接收
unique_ptr<vector<int>> sp{new vector<int>};
// p和sp的區別就是,如果在下面操作中發生異常中止或者直接返回,p會執行不了delete,而因為sp是unique_ptr指針分配的,所以會保證在程序中止時釋放掉sp的資源。
if (i < 77) {
return;
}
// 釋放普通指針變量的資源。
delete p;
}
int main() {
f(1, 1);
// 其實我們完全不必要使用new,以及指針和引用,因為很容易不小心就變成了濫用。
vector<int> p;
p = {1, 2};
cout << p[1];
}
所以,
我們的目標是,盡量不適用new,不適用指針和引用,多使用標准庫來寫高可用代碼,如果實在需要使用指針,那么更夠保證自己釋放資源的unique_ptr。
不要有錯覺,unique_ptr指針並不比普通指針消耗時空代價更大。
通過unique_ptr,我們還可以把自由存儲上申請的對象傳遞給函數或者從函數中傳出來。
就像vector是對象序列的句柄一樣(本身大小是固定的,因為它只是一個指向真正存儲空間的地址數據),unique_ptr是一個獨立對象或數組的句柄。他們都是以RAII機制控制其他對象的生命周期,並且都通過移動操作使得return語句簡單高效。
shared_ptr
shared_ptr在很多方面與unique_ptr非常相似。唯一的區別是shared_ptrd的對象使用拷貝操作而非移動操作。什么意思?shared_ptr是共享的意思,所以不能操作源文件,必須通過復制多份分發出來多個shared_ptr共享該對象的所有權。
只有在最后一個shared_ptr被銷毀時才會銷毀源對象。而unique_ptr是唯一所有權,它銷毀了原對象也會跟着銷毀。
而這句話的另一個意思就是shared_ptr的垃圾回收機制很不穩妥,因為程序可能對多份的shared_ptr管理失控,就會造成原對象永遠不被銷毀的情況,所以與析構函數相比,shared_ptr的垃圾回收需要慎重使用。除非你確實需要共享所有權,否則不用輕易使用shared_ptr。
而且,shared_ptr還有一個問題就是它本身並沒有指定任何規則用以指明共享指針的哪個擁有者有權讀寫對象。因此盡管在一定程度上解決了資源管理問題,但是數據競爭和其他形式的數據混淆依然存在(這是很可怕的)。
任務和線程
幾個概念:
- 任務,task,是指那些可以與其他計算並行執行的計算。
- 線程,thread,是任務在程序中的系統級標表示。
//
// Created by liuwenbin on 18-4-19.
//
#include <thread>
#include <iostream>
#include <string>
using namespace std;
// The function we want to execute on the new thread.
void task1(string msg) {
cout << "task1: " << msg << endl;
}
void task2(int i) {
cout << " task2:" << i << " Hello" << endl;
};
struct F {
void operator()() {
cout << " F():" << "Parallel World!" << endl;
};// 調用運算符()
};
int main() {
thread t1(task1, "Hello");// 函數作為task
thread t2(task2, 2);
thread t3{F()};//函數對象task
cout << "Concurrency has started!" << endl;
t1.join();// join=等待線程結束
t2.join();
t3.join();
cout << "Concurrency completed!" << endl;
}
到這還需要配置一下CMakeList.txt,才能繼續執行:
set(CMAKE_CXX_FLAGS -pthread)
加入線程支持以后,以上程序才可以正常運行了。下面是第一次輸出:
task1: HelloConcurrency has started!
F():Parallel World!
task2:2 Hello
Concurrency completed!
第二次輸出:
task1: F():Hello task2:2 Hello
Parallel World!
Concurrency has started!
Concurrency completed!
可以看出,cout輸出的內容是不可控的,這正是多線程自然執行的結果。
一個程序中所有線程共享單一地址空間。在這一點上線程與進程不同,進程間通常不直接共享數據。由於共享單一地址空間,因此線程間可通過共享對象相互通信。通常通過鎖或其他防止數據競爭(對變量的不受控制的並發訪問)的機制來控制線程間通信。
任務本身應該是完全隔離的,自主利用資源執行,但是任務間的通信應該以一種簡單而明顯的方式進行。
思考一個並發任務的最簡單的方式是:
把它看做一個可以與調用者並發執行的函數。
為此,我們只需要傳遞實參、獲取結果並保證兩者不會同時使用共享數據(不存在數據競爭)即可。
傳遞參數
//
// Created by liuwenbin on 18-4-19.
//
#include <vector>
#include <thread>
using namespace std;
void f(vector<double> &v){};//聲明一個函數f
struct FF {
vector<double> &v;// 以成員的方式保存了一個向量(vector是指向一個參數,變量都使用引用&)
FF(vector<double> &vv) : v{vv} {}// 這里的單冒號是指使用成員。
void operator()(){};
};
int main() {
vector<double> some_vec{1, 2, 3, 4, 5, 6};
vector<double> vec2{10, 11, 12, 13, 14, 15};
thread t1{f, ref(some_vec)};// thread的可變參數模板構造函數。using the reference wraper with thread
thread t2{FF{vec2}};// 以值傳遞的方式可保證其他線程不會訪問vec2【因為值傳遞是復制一份值傳遞過去而不會修改vec2本身。】
t1.join();
t2.join();
}
成功執行。
注意:undefined reference to 'XXX' 錯誤都是因為使用了一個沒有被實現的接口。所以將上面的方法聲明都加入花括號空實現也可以。
編譯器檢查第一個參數(函數或函數對象)是否可用后續的參數來調用,如果檢查通過,就構造一個必要的函數對象並傳遞給線程。因此FF和f執行相同的算法,任務的處理大致相同:
他們都為thread構造了一個函數對象來執行任務。
返回結果
在上面的例子中,是通過一個非const引用向線程中傳遞參數。只有當希望任務有權修改引用所引的數據時,才會這么做。而正規的一般的做法是:
將輸入數據以const引用的方式傳遞,並將保存結果的內存地址作為第二個參數傳遞給線程。
//
// Created by liuwenbin on 18-4-19.
//
#include <vector>
#include <iostream>
#include <thread>
using namespace std;
void f(const vector<double> &v, double *res) {};// 從v獲取輸入,將結果放入*res
class F {
public:
F(const vector<double> &vv, double *p) : v{vv}, res{p} {}
void operator()() {};// 將結果放入*res
private:
const vector<double> &v;// 輸入源
double *res;//輸出目標
};
int main() {
vector<double> some_vec;
vector<double> vec2;
double res1;
double res2;
thread t1{f, some_vec, &res1};// f(some_vec,&res1)在一個獨立線程中執行
thread t2{F{vec2, &res2}};// 相當於F{vec2,&res2}(),在一個獨立線程中執行
t1.join();
t2.join();
cout << res1 << ' ' << res2 << '\n';
}
輸出:
3.12431e-317 2.07441e-317
通過參數返回結果也不一定是很優雅的方法,所以C++ 的魅力就在於實現不定式(雖然C++ 的類有不變式的概念,呵啊呵)。
共享數據
在多個任務中,同時訪問數據是很常見的同步需求,然而如果數據是不變的,所有任務來查看這是沒問題的,除此之外,我們要確保在同一時刻至多有且有一個任務可以訪問給定的對象。
我們會采用互斥對象mutex來解決這個問題。thread使用lock()操作來獲取一個互斥對象:
#include <mutex>
using namespace std;
mutex m;//控制共享數據訪問的mutex
int sh;// 共享的數據(模擬)
void f() {
unique_lock<mutex> lck{m};//獲取mutex
sh += 7;// 處理共享數據(修改數據,不僅僅是查看)
}// 隱式釋放mutex
流程:
- unique_lock的構造函數獲取了互斥對象(通過調用m.lock())。
- 一個線程已經獲取了互斥對象,則其他線程會等待(進入阻塞狀態)。
- 當前線程完成對共享數據的訪問,unique_lock會釋放mutex(通過調用m.unlock())。
死鎖
當thread1獲取了mutex1然后試圖獲取mutex2,而同時thread2獲取了mutex2然后試圖獲取mutex1。(兩個線程掐上了正好)這就是死鎖。
思考
這一節基於mutex上鎖解鎖的共享數據機制,實際上並不比參數拷貝和結果返回好,現代計算機的效率已經很高,可能的話,使用后者吧而不是輕易上鎖。
condition_variable
通過外部事件實現線程間通信的基本方法是使用condition_variable,它提供了一種機制:
允許一個thread等待另一個threa。特別是,它允許一個thread等待某個條件(condition,通常稱為一個事件,event)發生,這種條件通常是其他thread完成工作產生的結果。
//
// Created by liuwenbin on 18-4-19.
//
#include <queue>
#include <condition_variable>
#include <mutex>
#include <thread>
#include <chrono>
using namespace std;
class Message {// 通信對象
};
queue<Message> mqueue;// 消息隊列
condition_variable mcond;//通信用的條件變量
mutex mmutex;// 鎖機制
/**
* 消費者
*/
void consumer() {
while (true) {//無限循環
unique_lock<mutex> lck{mmutex};//獲取mmutex鎖:上鎖
while (mcond.wait_for(lck, chrono::milliseconds{20}, true)) {//釋放lck並等待20毫秒
}
// 被喚醒后重新獲取lck
auto m = mqueue.front();//獲取消息
mqueue.pop();// 從隊列中彈出消息
lck.unlock();// 釋放lck
}
}
/*
* 生產者
*/
void producer() {
while (true) {
Message m;
//...處理
unique_lock<mutex> lck{mmutex};//保護隊列上的操作:上鎖
mqueue.push(m);// 隊列壓入信息對象
mcond.notify_one(); // 通知
// 釋放鎖(在作用域結束)
}
}
生產者和消費者是並發線程,消費者若先獲得鎖,然后通過condition_variable解鎖,釋放對共享對象mqueue的所有權,停留在阻塞狀態。同時,生成者會獲得該所有權然后生產數據存入對象mqueue,同時通過與消費者同一個condition_variable對象的通知notify函數,告訴消費者阻塞位置“我已生產好了,你用吧!”,消費者會被喚醒,重新獲得鎖,然后可以對共享對象mqueue進行訪問處理。
不過還是那句話,共享數據一定要用么?大部分時間我們直接使用拷貝參數和結果返回的形式了。除非很必要的必須維護同一個數據對象的時候,那就可以考慮condition_variable來做。
任務通信
上面一直在將線程通信的范疇,我們討論了共享數據的方式,多線程並發的模型,上鎖解鎖的機制等。然而標准庫其實提供了一些特性,允許程序員在抽象的任務層(工作並發執行)進行操作,而不是在底層的線程和鎖的層次直接進行操作。有三種機制:future和promise,packaged_task, async()。
future和promise
用來從一個獨立線程上創建出的任務返回結果。他們允許在兩個任務間傳輸值,而無須顯式使用鎖,高效地實現多線程間傳輸。
基本思路: 當一個任務需要向另一個任務傳輸某個值時,它把值放入promise中。具體的C++ 實現以自己的方式令這個值出現在對應的future中,然后就可以從其中讀到這個值了。
通過這個圖,可以有效地理解future-promise流程。
packaged_task
消費者的future和生產者的promise,如何引入?
packaged_task 就是標准庫提供用來簡化future和promise設置的:
- 它提供了一層包裝代碼,負責把某個任務的返回值或異常放入一個promise中。
- 如果get_future()向一個packaged_task發出請求,它會返回給你對應的promise和future。
//
// Created by liuwenbin on 18-4-19.
//
#include <vector>
#include <numeric>
#include <functional>
#include <iostream>
#include <future>
using namespace std;
double accum(double *beg, double *end, double init) {
// 注意:accumulate在庫<numeric>中。
return accumulate(beg, end, init);// 計算(beg:end)中元素的和,結果的原始值是init,如果init為10,那么無論beg,end啥樣,結果要先加10。
}
void comp2() {
vector<double> v{33, 10, 123, 1, 3};
// using 別名關鍵字。可以全局命名空間,也可以定義一個結構的別名:任何作用域內提到別名的時候就可以用它的定義代替。
using Task_type = double(double *, double *, double);
packaged_task<Task_type> pt0{accum};// 封裝了promise和future,通過別名的結構打包任務
packaged_task<Task_type> pt1{accum};
future<double> f0{pt0.get_future()};// 獲取pt0的future,現在我們有一個future對象f0了。
future<double> f1{pt1.get_future()};
double *first = &v[0];// 找到起始位置。
// 為pt0啟動一個線程
thread t1{move(pt0), first, first + v.size() / 2, 0};//packaged_task不能被拷貝,所以要使用移動move()操作。
// 為pt1啟動一個線程
thread t2{move(pt1), first + v.size() / 2, first + v.size(), 0};
cout << f0.get() << endl;
cout << f1.get() << endl;
// 別忘記加join,等待執行完畢在關閉總程序。
t1.join();
t2.join();
}
int main() {
comp2();
}
輸出:
43
127
具體操作看注釋,總結一下,packaged_task讓我們不必顯式地調用鎖,同時有可以在線程間進行數據通信。
以上代碼的意思:5個數,算他們的和,為了更高效率,我們用兩個線程,第一個算前兩個數的和,第二個線程算后三個數的和,這兩個線程並發,然后通過join等待他們並發結束,主線程再針對兩個線程返回的結果相加獲得最終的結果。這就通過多線程機制讓工作變得更加高效,也提升了對系統資源的利用率。
async()
整個這一章並發,多線程的實現思路是:將任務當做可以與其他任務並發執行的函數來處理。這是簡單又強大的。
如果需要啟動可異步運行的任務,可使用async()。
void comp3(vector<double> &v) {
double *first = &v[0];// 找到起始位置。
auto v0 = &v[0];
auto sz = v.size();
auto f0 = async(accum, v0, v0 + sz / 2, 0);// 先算前一半
auto f1 = async(accum, v0 + sz / 2, v0 + sz, 0);// 再算后一半
//通過async異步執行,不必再操心線程和鎖,只考慮可能異步執行的任務拆分即可。
cout << f0.get() << endl;
cout << f1.get() << endl;
}
int main() {
vector<double> v{33, 10, 123, 1, 3};
comp3(v);
}
輸出:
43
127
限制:不要試圖對共享資源且需要用鎖機制的任務使用async(),實際上thread全都是封裝在async內部,是由async來決定的,它根據它所了解的調用發生時,系統可用資源量來確定使用多少個thread。例如async會先檢查有多少可用核(處理器)再確定啟動多少個thread。
請注意,async異步強調的是不占用主線程,可以另外開啟一個線程異步操作,而讓主程序繼續執行。
這一部分我們徹底放下了鎖機制和線程的易碎體質,使用了標准庫提供的更好的多線程實現工具。所以在程序設計時要從並發執行任務的角度,而不是直接從thread的角度思考。
實用工具
以上基本把C++ 所有的知識大概捋了一遍,而除了上面介紹到的標准庫組件,標准庫還提供了一些不太顯眼但應用非常廣泛的小工具組件:
- clock和duration,屬於
庫。通過統計程序運行時間,是獲得性能表現最好的指標。 - iterator_traits和is_arithmetic類型函數,用於獲取關於類型的信息。(類型函數,指在編譯時求值的函數,它接受一個類型作為實參或者返回一個類型作為結果。)
- pair和tuple,用於標識規模較小且由異構數據組成的集合。
-
庫定義了正則表達式相關的支持內容,用來簡化模式匹配的任務。關於正則,請看 《正則表達式》 - 還有就是上面提到過的一些專門領域的支持,例如數學運算:復數、隨機數,算法、向量算術等內容,注意我們不要重復造輪子,優先使用這些庫而不是語言本身去自創。
- 可以使用numeric_limits訪問數值類型的屬性,例如float的最高階,int所占的字節數等。
總結
本文長篇大論,實際上都是C++ 最入門的知識,我們可以直接去查標准庫或其他優秀庫boost等,但若要真的掌握一門語言,在開始查找以前,從頭到尾了解清楚這門語言是什么,它的設計思想,它都涵蓋了哪些內容,這是非常重要的。所以本文從C++ 的設計思想開始,總結了Java程序員學習C++ 應該發揚和規避的一些問題,然后具體介紹了C++ 的基礎知識,抽象機制,容器,算法,並發以及其他一些實用工具。其中涉及到的代碼演練部分,都經過本地編譯環境的測試,語言本身的關鍵字也都有所介紹。最后,想真的掌握C++,要去理解語言本身的設計思想而不必硬扣細節特性。之后,我們會進入C++ 優秀項目的源碼學習,在這個階段,我們將丟下身上的書生氣,切實地應用工業級代碼規范,去熟悉更多優秀庫的使用。
參考資料
The C++ Programming Language, Fourth Edition. Bjarne Stroustrup.
源碼請參照玉如意的github
更多文章請轉到醒者呆的博客園。
我的博客即將搬運同步至騰訊雲+社區,邀請大家一同入駐:https://cloud.tencent.com/developer/support-plan?invite_code=7591a70cxj4z