C++避坑指南
?
導語:如果,將編程語言比作武功秘籍,C++無異于《九陰真經》?!毒抨幷娼洝吠姶蟆⒉┐缶?#xff0c;經中所載內功、輕功、拳、掌、腿、刀法、劍法、杖法、鞭法、指爪、點穴密技、療傷法門、閉氣神功、移魂大法等等,無所不包,C++亦如是。
C++跟《九陰真經》一樣,如果使用不當,很容易落得跟周芷若、歐陽鋒、梅超風等一樣走火入魔。這篇文章總結了在學習C++過程中容易走火入魔的一些知識點。為了避免篇幅浪費,太常見的誤區(如指針和數組、重載、覆蓋、隱藏等)在本文沒有列出,文中的知識點也沒有前后依賴關系,各個知識點基本是互相獨立,并沒有做什么鋪墊,開門見山直接進入正文。
目錄
1?函數聲明和對象定義
2?靜態對象初始化順序
3?類型轉換
3.1?隱式轉換
3.2?顯示轉換
4 inline內聯
5?名稱查找
5.1?受限名稱查找
5.2?非受限名稱查找
6?智能指針
6.1 std::auto_ptr
6.2 std::shared_ptr
6.3 std::unique_ptr
7 lambda表達式
對象定義寫成空的初始化列表時,會被解析成一個函數聲明??梢圆捎么a中的幾種方法定義一個對象。
//這是一個函數聲明 //不是一個對象定義 string foo(); //函數聲明 string foo(void); //對象定義幾種方法 string foo; string foo{ };//c++11 string *foo = new string; string *foo = new string(); string *foo = new string{ };//c++11(左滑可以查看全部代碼,下同)
在同一個編譯單元中,靜態對象的初始化次序與其定義順序保持一致。對于作用域為多個編譯單元的靜態對象,不能保證其初始化次序。如下代碼中,在x.cpp和y.cpp分別定義了變量x和y,并且雙方互相依賴。
//x.cpp extern int y; int x = y + 1;x.cpp中使用變量y來初始化x
y.cpp中使變量x來初始化y
//main.cpp extern int x; extern int y; int main() { cout << "x = " << x << endl; cout << "y = " << y << endl; return 0; }
如果初始化順序不一樣,兩次執行的結果輸出不一樣,如下所示:
g++ main.cpp x.cpp y.cpp ./a.out x = 1 y = 2如果我們需要指定依賴關系,比如y依賴x進行初始化,可以利用這樣一個特性來實現:函數內部的靜態對象在函數第一次調用時初始化,且只被初始化一次。使用該方法,訪問靜態對象的唯一途徑就是調用該函數。改寫后代碼如下所示:
//x.h extern int &getX(); //x.cpp int &getX() { static int x; return x; }getX()函數返回x對象
y對象使用x對象進行初始化
打印x和y值。通過這種方式,就保證了x和y的初始化順序。
這里只描述自定義類的類型轉換,不涉及如算數運算的類型自動提升等。
3.1?隱式轉換
C++自定義類型在以下兩種情況會發生隱式轉換:
1) 類構造函數只有一個參數或除第一個參數外其他參數有默認值;
2) 類實現了operator type()函數;
上面定義了一個Integer類,Integer(int)構造函數可以將int隱式轉換為Integer類型。operator int()函數可以將Integer類型隱式轉換為int。從下面代碼和輸出中可以看出確實發生了隱式的類型轉換。
int main() { Integer value1; value1 = 10; cout?<<?"value1="?<<?value1?<<?endl; cout?<<?"*******************"?<<?endl; int value2 = value1; cout?<<?"value2="?<<?value2?<<?endl; return 0; }隱式類型轉換在某些場景中確實比較方便,如:
a、運算符重載中的轉換,如可以方便的使Integer類型和內置int類型進行運算
const Integer operator+(const Ingeter &lhs, const Ingeter &rhs) { return Integer(lhs.m_value + rhs.m_value); } Integer value = 10; Integer sum = value + 20;b、條件和邏輯運算符中的轉換,如可以使智能指針像原生裸指針一樣進行條件判斷
template<typename T> class AutoPtr { public: operator bool() const { return m_ptr; } private: T *m_ptr; }; AutoPtr<int> ptr(new int(10)); if(ptr) { //do something }隱式類型轉換在帶來便利性的同時也帶來了一些坑,如下所示:
template<typename T> class Array { public: Array(int size); const T &operator[] (int index); friend bool operator==(const Array<T> &lhs, const Array<T> &rhs); }; Array<int> arr1(10); Array<int> arr2(10); if(arr1 == arr2[0]) { //do something }構造函數隱式轉換帶來的坑。上述代碼定義了一個Array類,并重載了operator==運算符。本意是想比較兩個數組,但是if(arr1 == arr2)誤寫成了f(arr1 == arr2[0]),編譯器不會抱怨,arr2[0]會轉換成一個臨時Array對象然后進行比較。
class String { public: String(const char *str); operator const char* () const{ return m_data; } private: char *m_data; }; const char *strcat(const char *str1, const char *str2) { String str(str1); str.append(str2); return str; }operator type()帶來的坑。上述String類存在到const char *的隱式轉換,strcat函數返回時String隱身轉換成const char *,而String對象已經被銷毀,返回的const char *指向無效的內存區域。這也是std::string不提提供const char *隱式轉換而專門提供了c_str()函數顯示轉換的原因。
3.2?顯示轉換
正是由于隱式轉換存在的坑,C++提供explicit關鍵字來阻止隱式轉換,只能進行顯示轉換,分別作用域構造函數和operator(),如下所示:
1)?explicit?Ctor(const type &);
2)?explicit?operator type();
class Integer { public: Integer() : m_value(0) { } //int --> Integer explicit Integer(int value) : m_value(value) { cout << "Integer(int)" << endl; } //Integer --> int explicit operator int() { cout << "operator int()" << endl; return m_value; } private: int m_value; };
用explicit改寫Integer類后,需要進行顯示轉換才能與int進行運算,如下:
int main() { Integer value1; //value1 = 10; //compile error value1 = static_cast<Integer>(10); cout << "value1=" << (int)value1 << endl; //int value2 = value1; //compile error int value2 = static_cast<int>(value1); cout << "value2=" << value2 << endl; return 0; }為了保持易用性,C++11中explicit operator type()在條件運算中,可以進行隱式轉換,這就是為什么C++中的智能指針如shared_ptr的operator bool()加了explicit還能直接進行條件判斷的原因。下面代碼來自shared_ptr源碼。
explicit operator bool() const _NOEXCEPT { // test if shared_ptr object owns a resource return (get() != nullptr); }內聯類似于宏定義,在調用處直接展開被調函數,以此來代替函數調用,在消除宏定義的缺點的同時又保留了其優點。內聯有以下幾個主要特點:
a、內聯可以發生在任何時機,包括編譯期、鏈接期、運行時等;
b、編譯器很無情,即使你加了inline,它也可能拒絕你的inline;
c、編譯器很多情,即使你沒有加inline,它也可能幫你實施inline;
d、不合理的inline會導致代碼臃腫。
使用內聯時,需要注意以下幾個方面的誤區:
1)inline函數需顯示定義,不能僅在聲明時使用inline。類內實現的成員函數是inline的。
inline int add(int, int); int add(int x, int y) //no inline { return x + y; } class Calculator { public: static?int?add(int?x,?int?y)?//inline { return x + y; } static int sub(int x, int y); }; int Calculator::sub(int x, int y) //no inline { return x - y; }2)通過函數指針對inline函數進行調用時,編譯器有可能不實施inline
inline int add(int x, int y) { return x + y; } //定義函數指針 int (*pfun)(int, int) = add; add(3, 5); //實施inline pfun(3, 5); //通過函數指針調用,可能無法inline3)編譯器可能會拒絕內聯虛函數,但可以靜態確定的虛函數對象,多數編譯器可以inline
class Animal { public: virtual void walk() = 0; }; clas Penguin : public Animal { public: virtual void walk(){ } } Animal *p1 = new Penguin(); p1->walk(); //大多數編譯器無法inline Penguin *p2 = new Penguin(); p2->walk(); //大多數編譯器可以inline4)inline函數有局部靜態變量時,可能無法內聯
inline void report(int code) { static int counter = 0; doReport(code, ++counter); } report(-9998);//可能無法inline5)直接遞歸無法inline,應轉換成迭代或者尾遞歸。下面分別以遞歸和迭代實現了二分查找。
template<typename T> inline int recursionSearch(const vector<T> &vec, const T &val, int low, int high) { if(low > high) return -1; int mid = (low + high) / 2; if(val < vec[mid]) { return recursionSearch(vec, val, low, mid - 1); } else if(val > vec[mid]) { return recursionSearch(vec, val, mid + 1, high); } else { return mid; } }二分查找的遞歸方式實現。
二分查找的迭代方式實現。
分別調用二分查找的遞歸和迭代實現,開啟-O1優化,通過查看匯編代碼和nm查看可執行文件可執行文件符號,只看到了遞歸版本的call指令和函數名符號,說明遞歸版本沒有內聯,而迭代版本實施了內聯展開。
6)構造函數和析構函數可能無法inline,即使函數體很簡單
class Student : public Person { public: Student() { }; virtual ~Student() { } private: School m_school; }; Student::Student() { try { Person::Person(); //構造基類成分 } catch(...) { throw; } try { m_school.School::School();//構造m_school; } catch(...) { Person::~Person(); throw; } }表面上構造函數定義為空且是inline,但編譯器實際會生成如右側的偽代碼來構造基類成分和成員變量,從而不一定能實施inline。
C++中名稱主要分為以下幾類:
a)?受限型名稱:使用作用域運算符(::)或成員訪問運算符(.和->)修飾的名稱。
如:::std、std::sort、penguin.name、this->foo等。
b)?非受限型名稱:除了受限型名稱之外的名稱。
如:name、foo
c)?依賴型名稱:依賴于形參的名稱。
如:vector<T>::iterator
d)?非依賴型名稱:不屬于依賴型名稱的名稱。
如:vector<int>::iterator
5.1?受限名稱查找
受限名稱查找是在一個受限作用域進行的,查找作用域由限定的構造對象決定,如果查找作用域是類,則查找范圍可以到達基類。
class B { public: int m_i; }; class D : public B { } void foo(D *pd) { pd->m_i = 0; //查找作用域到達基類B,即 B::m_i; }5.2?非受限名稱查找
5.2.1?普通查找:由內向外逐層查找,存在繼承體系時,先查找該類,然后查找基類作用域,最后才逐層查找外圍作用域
extern string name; //(1) string getName(const string &name) //(2) { if(name.empty()) { string name = "DefaultName"; //(3) return getName(name); //引用name(3) } return name/*引用name(2)*/ + ::name/*引用name(1)*/; }5.2.2?ADL(argument-dependent lookup)查找:又稱koenig查找,由C++標準委員會Andrew Koenig定義了該規則——如果名稱后面的括號里提供了一個或多個類類型的實參,那么在名稱查找時,ADL將會查找實參關聯的類和命名空間。
namespace ns { class?C{??}; void?foo(const?C?&c) {? cout << "foo(const C &)" << endl; } } int main() { ns::C c; foo(c); return 0; }根據類型C的實參c,ADL查找到C的命名空間ns,找到了foo的定義。
了解了ADL,現在來看個例子,下面代碼定義了一個Integer類和重載了operator<運算符,并進行一個序列排序。
namespace ns { class?Integer { public: explicit?Integer(int?value)?:?m_value(value){??} int m_value = 0; }; } bool operator<(const Integer &lhs, const Integer &rhs) { return lhs.m_value < rhs.m_value; } int main() { using ns::Integer; std::vector<Integer> v = {Integer(1), Integer(5), Integer(1), Integer(10)}; std::sort(v.begin(), v.end()); for(auto const &item : v) { std::cout << item.m_value << " "; } std::cout << std::endl; return 0; }上面的代碼輸出什么? 1 1 5 10嗎。上面的代碼無法編譯通過,提示如下錯誤
/usr/include/c++/4.8.2/bits/stl_heap.h:235:35: 錯誤:no match for ‘operator<’ (operand types are ‘ns::Integer’ and ‘ns::Integer’) if (*(__first + __secondChild) < *(__first + (__secondChild - 1)))operator<明明在全局作用于有定義,為什么找不到匹配的函數?前面的代碼片段,應用ADL在ns內找不到自定義的operator<的定義,接著編譯器從最近的作用域std內開始向外查找,編譯器在std內找到了operator<的定義,于是停止查找。定義域全局作用域的operator<被隱藏了,即名字隱藏。名字隱藏同樣可以發生在基類和子類中。好的實踐:定義一個類時,應當將其相關的接口(包括自由函數)也放入到與類相同的命名空間中。
namespace ns { class Integer { public: explicit Integer(int value) : m_value(value){ } int m_value = 0; }; bool operator<(const Integer &lhs, const Integer &rhs) { return lhs.m_value < rhs.m_value; } }把operator<定義移到ns命名空間后運行結果正常
再來看一個名稱查找的例子。
template<typename U> struct B { typedef int T; }; template<typename T> struct D1 : B<int> { T m_value; }; int main() { int value = 10; D1<int *> d1; d1.m_value = &value; cout << *d1.m_value << endl; return 0; }這段代碼編譯時提示如下錯誤,我們用int *實例化D1的模板參數并給m_value賦值,編譯器提示無法將int *轉換成int類型,也就是m_value被實例化成了int而不是int *。
我們將代碼改動一下,將D2繼承B<int>改為B<T>,代碼可以順利編譯并輸出。
template<typename U> struct B { typedef int T; }; template<typename T> struct D2 : B<T> { T m_value; }; int main() { int value = 10; D2<int *> d2; d2.m_value = &value; cout << *d2.m_value << endl; return 0; }D1和D2唯一的區別就是D1繼承自B<int>,D2繼承自B<T>。實例化后,為何D1.m_value類型是int,而D2.m_value類型是int *。在分布式事務領域有二階段提交,在并發編程設計模式中二階段終止模式。在C++名稱查找中也存在一個二階段查找。
二階段查找(two-phase lookup):首次看到模板定義的時候,進行第一次查找非依賴型名稱。當實例化模板的時候,進行第二次查找依賴型名稱。
D1中查找T時,基類B<int>是非依賴型名稱,無需知道模板實參就確定了T的類型。
D2中查找T時,基類B<T>是依賴型名稱,在實例化的時候才會進行查找。
6?智能指針
6.1 std::auto_ptr
std::auto_ptr是C++98智能指針實現,復制auto_ptr時會轉移所有權給目標對象,導致原對象會被修改,因此不具備真正的復制語義,不能將其放置到標準容器中。auto_ptr在c++11中已經被標明棄用,在c++17中被移除。
auto_ptr<string> ap1(new string("foo")); auto_ptr<string> ap2 = ap1; //內存訪問錯誤,ap1管理的指針已經被置位空 string str(*ap1); auto_ptr<string> ap3(new string("bar")); vector<auto_ptr<string>> ptrList; //ap2和ap3被復制進容器后其管理的指針對象為空 //違反標準c++容器復制語義 ptrList.push_back(ap2); ptrList.push_back(ap3);6.2 std::shared_ptr
std::shared_ptr采用引用計數共享指針指向對象所有權的智能指針,支持復制語義。每次發生復制行為時會遞增引用計數,當引用計數遞減至0時其管理的對象資源會被釋放。但shared_ptr也存在以下幾個應用方面的陷阱。
1)勿通過傳遞裸指針構造share_ptr
{ string *strPtr = new string("dummy"); shared_ptr<string> sp1(strPtr); shared_ptr<string> sp2(strPtr); }這段代碼通過一個裸指針構造了兩個shared_ptr,這兩個shared_ptr有著各自不同的引用計數,導致原始指針被釋放兩次,引發未定義行為。
2)勿直接將this指針構造shared_ptr對象
class Object { public: shared_ptr<Object> GetSelfPtr() { return shared_ptr<Object>(this); } }; shared_ptr<Object> sp1(new Object()); shared_ptr<Object> sp2 = sp1->GetSelfPtr();這段代碼使用同一個this指針構造了兩個沒有關系的shared_ptr,在離開作用域時導致重復析構問題,和1)是一個道理。當希望安全的將this指針托管到shared_ptr時,目標對象類需要繼承std::enable_shared_from_this<T>模板類并使用其成員函數shared_from_this()來獲得this指針的shared_ptr對象。如下所示:
class Object : public std::enable_shared_from_this<Object> { public: shared_ptr<Object> GetSelfPtr() { return shared_from_this(); } }; shared_ptr<Object> sp1(new Object()); shared_ptr<Object> sp2 = sp1->GetSelfPtr();3)請勿直接使用shared_ptr互相循環引用,如實在需要請將任意一方改為weak_ptr。
struct You; struct I { shared_ptr<You> you; ~I() { cout << "i jump" << endl; } }; struct You { shared_ptr<I> me; ~You() { cout << "you jump" << endl; } }; int main() { shared_ptr<I> i(new I()); shared_ptr<You> you(new You()); i->you = you; you->me = i; return 0; }代碼運行結果,沒有看到打印任何內容,析構函數沒有被調用。最終你我都沒有jump,完美的結局。但是現實就是這么殘酷,C++的世界不允許他們不jump,需要將其中一個shared_ptr改為weak_ptr后資源才能正常釋放。
4)優先使用make_shared而非直接構造shared_ptr。make_shared主要有以下幾個優點:
a、可以使用auto自動類型推導。
shared_ptr<Object> sp(new Object());
auto sp = make_shared<Object>();
?b、減少內存管理器調用次數。shared_ptr的內存結構如下圖所示,包含了兩個指針:一個指向其所指的對象,一個指向控制塊內存。
shared_ptr<Object> sp(new Object());?這條語句會調用兩次內存管理器,一次用于創建Object對象,一次用于創建控制塊。如果使用make_shared會一次性分配內存同時保存Object和控制塊。
c、防止內存泄漏。
class Handler; string getData(); int handle(shared_ptr<Handler> sp, const string &data); //調用handle handle(shared_ptr<Handler>(new Handler()), getData());這段代碼可能發生內存泄漏。一般情況下,這段代碼的調用順序如下:
new Handler()? ? ?① 在堆上創建Handler對象
shared_ptr()? ? ? ? ②創建shared_ptr
getData()? ? ? ? ? ? ?③調用getData()函數
但是編譯器可能不按照上述①②③的順序來生成調用代碼??赡墚a生①③②的順序,此時如果③getData()產生異常,而new Handler對象指針還沒有托管到shared_ptr中,于是內存泄漏發生。使用make_shared可以避免這個問題。
handle(make_shared<Handler>(),?getData());這條語句在運行期,make_shared和getData肯定有一個會先調用。如果make_shared先調用,在getData被調用前動態分配的Hander對象已經被安全的存儲在返回的shared_ptr對象中,接著即使getData產生了異常shared_ptr析構函數也能正常釋放Handler指針對象。如果getData先調用并產生了異常,make_shared則不會被調用。
但是make_shared并不是萬能的,如不能指定自定義刪除器,此時可以先創建shared_ptr對象再傳遞到函數中。
shared_ptr<Handler> sp(new Handler()); handle(sp, getData());6.3 std::unique_ptr
std::unique_ptr是獨占型智能指針,僅支持移動語義,不支持復制。默認情況下,unique_ptr有著幾乎和裸指針一樣的內存開銷和指令開銷,可以替代使用裸指針低開銷的場景。
1)與shared_ptr不同,unique_ptr可以直接指向一個數組,因為unique_ptr對T[]類型進行了特化。如果shared_ptr指向一個數組,需要顯示指定刪除器。
unique_ptr<T []> ptr(new T[10]); //顯示指定數組刪除器 shared_ptr<T> ptr(new T[10], [](T *p){delete[] p;});?2)與shared_ptr不同,unique_ptr指定刪除器時需要顯示指定刪除器的類型。
shared_ptr<FILE> pf(fopen("data.txt", "w"), ::fclose); //顯示指定數組刪除器類型 unique_ptr<FILE, int(*)(FILE *)> pf(fopen("data.txt", "w"), ::fclose); unique_ptr<FILE, std::function<int(FILE *)>> pf(fopen("data.txt", "w"), ::fclose);1)捕獲了變量的lambda表達式無法轉換為函數指針。
using FunPtr = void(*)(int *); FunPtr ptr1 = [](int *p) { delete p; }; FunPtr ptr2 = [&](int *p) { delete p; };//錯誤
2)對于按值捕獲的變量,其值在捕獲的時候就已經確定了(被復制到lambda閉包中)。而對于按引用捕獲的變量,其傳遞的值等于lamdba調用時的值。
int a = 10; auto valLambda = [a] { return a + 10; }; auto refLambda = [&a] { return a + 10; }; cout << "valLambda result:" << valLambda() << endl; //20 cout << "refLambda result:" << refLambda() << endl; //20 a += 10; cout << "valLambda result:" << valLambda() << endl; //20 cout << "refLambda result:" << refLambda() << endl; //303)默認情況下,lambda無法修改按值捕獲的變量。如果需要修改,需要使用mutable顯示修飾。這其實也好理解,lambda會被編譯器轉換成operator() const的函數對象。
auto mutableLambda = [a]() mutable { return a += 10; };4)lambda無法捕捉靜態存儲的變量。
static int a = 10; auto valLambda = [a] { return a + 10; }; //錯誤總結
- 上一篇: 腾讯云培训认证中心开放日
- 下一篇: 窥见C++11智能指针