C++常見避坑指南
作者 | gouglegou
C++ 從入門到放棄?本文主要總結了在C++開發或review過程中常見易出錯點做了歸納總結,希望借此能增進大家對C++的了解,減少編程出錯,提升工作效率,也可以作為C++開發的避坑攻略。
一、空指針調用成員函數會crash??
當調用一個空指針所指向的類的成員函數時,大多數人的反應都是程序會crash。空指針并不指向任何有效的內存地址,所以在調用成員函數時會嘗試訪問一個不存在的內存地址,從而導致程序崩潰。
事實上有點出乎意料,先來看段代碼:
class MyClass {
public:
static void Test_Func1() {
cout << "Handle Test_Func1!" << endl;
}
void Test_Func2() {
cout << "Handle Test_Func2!" << endl;
}
void Test_Func3() {
cout << "Handle Test_Func3! value:" << value << endl;
}
virtual void Test_Func4() {
cout << "Handle Test_Func4!" << endl;
}
int value = 0;
};
int main() {
MyClass* ptr = nullptr;
ptr->Test_Func1(); // ok, print Handle Test_Func1!
ptr->Test_Func2(); // ok, print Handle Test_Func2!
ptr->Test_Func3(); // crash
ptr->Test_Func4(); // crash return 0;
}
上面例子中,空指針對Test_Func1和Test_Func2的調用正常,對Test_Func3和Test_Func4的調用會crash。可能很多人反應都會crash,實際上并沒有,這是為啥?
類的成員函數并不與具體對象綁定,所有的對象共用同一份成員函數體,當程序被編譯后,成員函數的地址即已確定,這份共有的成員函數體之所以能夠把不同對象的數據區分開來,靠的是隱式傳遞給成員函數的this指針,成員函數中對成員變量的訪問都是轉化成"this->數據成員"的方式。因此,從這一角度說,成員函數與普通函數一樣,只是多了this指針。而類的靜態成員函數只能訪問靜態成員變量,不能訪問非靜態成員變量,所以靜態成員函數不需要this指針作為隱式參數。
因此,Test_Func1是靜態成員函數,不需要this指針,所以即使ptr是空指針,也不影響對Test_Fun1的正常調用。Test_Fun2雖然需要傳遞隱式指針,但是函數體中并沒有使用到這個隱式指針,所以ptr為空也不影響對Test_Fun2的正常調用。Test_Fun3就不一樣了,因為函數中使用到了非靜態的成員變量,對num的調用被轉化成this->num,也就是ptr->num,而ptr是空指針,因此會crash。Test_Fun4是虛函數,有虛函數的類會有一個成員變量,即虛表指針,當調用虛函數時,會使用虛表指針,對虛表指針的使用也是通過隱式指針使用的,因此Test_Fun4的調用也會crash。
同理,以下std::shared_ptr的調用也是如此,日常開發需要注意,記得加上判空。
std::shared_ptr<UrlHandler> url_handler;
...
if(url_handler->IsUrlNeedHandle(data)) {
url_handler->HandleUrl(param);
}
二、字符串相關
1.字符串查找
對字符串進行處理是一個很常見的業務場景,其中字符串查找也是非常常見的,但是用的不好也是會存在各種坑。常見的字符串查找方法有:std::string::find、std::string::find_first_of、std::string::find_first_not_of、std::string::find_last_of,各位C++ Engineer都能熟練使用了嗎?先來段代碼瞧瞧:
bool IsBlacklistDllFromSrv(const std::string& dll_name) {
try {
std::string target_str = dll_name;
std::transform(target_str.begin(), target_str.end(), target_str.begin(), ::tolower);
if (dll_blacklist_from_srv.find(target_str) != std::string::npos) {
return true;
}
}
catch (...) {
}
return false;
}
上面這段代碼,看下來沒啥問題的樣子。但是仔細看下來,就會發現字符串比對這里邏輯不夠嚴謹,存在很大的漏洞。std::string::find只是用來在字符串中查找指定的子字符串,只要包含該子串就符合,如果dll_blacklist_from_srv = "abcd.dll;hhhh.dll;test.dll" 是這樣的字符串,傳入d.dll、hh.dll、dll;test.dll也會命中邏輯,明顯是不太符合預期的。
這里順帶回顧下C++ std::string常見的字符串查找的方法:
- std::string::find 用于在字符串中查找指定的子字符串。如果找到了子串,則返回子串的起始位置,否則返回std::string::npos。用于各種字符串操作,例如判斷子字符串是否存在、獲取子字符串的位置等。通過結合其他成員函數和算法,可以實現更復雜的字符串處理邏輯。
- std::string::find_first_of 用于查找字符串中第一個與指定字符集合中的任意字符匹配的字符,并返回其位置。可用來檢查字符串中是否包含指定的某些字符或者查找字符串中第一個出現的特定字符
- std::string::find_first_not_of 用于查找字符串中第一個不與指定字符集合中的任何字符匹配的字符,并返回其位置。
- std::string::find_last_of 用于查找字符串中最后一個與指定字符集合中的任意字符匹配的字符,并返回其位置。可以用來檢查字符串中是否包含指定的某些字符,或者查找字符串中最后一個出現的特定字符
- std::string::find_last_not_of 用于查找字符串中最后一個不與指定字符集合中的任何字符匹配的字符,并返回其位置。
除了以上幾個方法外,還有查找滿足指定條件的元素std::find_if。
std::find_if 是 C++ 標準庫中的一個算法函數,用于在指定范圍內查找第一個滿足指定條件的元素,并返回其迭代器。需要注意的是,使用 std::find_if 函數時需要提供一個可調用對象(例如 lambda 表達式或函數對象),用于指定查找條件。
std::vector<int> vec = {1, 2, 3, 4, 5};
auto it = std::find_if(vec.begin(), vec.end(), [](int x) { return x % 2 == 0; });
if (it != vec.end()) {
std::cout << "Found even number: " << *it << std::endl;
}
此外,在業務開發有時候也會遇到需要C++ boost庫支持的starts_with、ends_with。如果用C++標準庫來實現,常規編寫方法可如下:
bool starts_with(const std::string& str, const std::string& prefix) {
return str.compare(0, prefix.length(), prefix) == 0;
}
bool ends_with(const std::string& str, const std::string& suffix) {
if (str.length() < suffix.length()) {
return false;
} else {
return str.compare(str.length() - suffix.length(), suffix.length(), suffix) == 0;
}
}
以上代碼中,starts_with 函數和 ends_with 函數分別用于檢查字符串的前綴和后綴。兩個函數內部都使用了 std::string::compare 方法來比較字符串的子串和指定的前綴或后綴是否相等。如果相等,則說明字符串滿足條件,返回 true;否則返回 false。
2.std::string與std::wstring轉換
對字符串進行處理是一個很常見的業務場景,尤其是C++客戶端開發,我們經常需要在窄字符串std::string與寬字符串std::wstring之間進行轉換,有時候一不小心就會出現各種中文亂碼。還有就是一提到窄字符串與寬字符串互轉以及時不時出現的中文亂碼,很多人就犯暈。
在 C++ 中,std::string和std::wstring之間的轉換涉及到字符編碼的轉換。如果在轉換過程中出現亂碼,可能是由于字符編碼不匹配導致的。要正確地進行std::string 和 std::wstring之間的轉換,需要確保源字符串的字符編碼和目標字符串的字符編碼一致,避免C++中的字符串處理亂碼,可以使用Unicode編碼(如UTF-8、UTF-16或UTF-32)來存儲和處理字符串。
我們想要處理或解析一些Unicode數據,例如從Windows REG文件讀取,使用std::wstring變量更能方便的處理它們。例如:std::wstring ws=L"中國a"(6個八位字節內存:0x4E2D 0x56FD 0x0061),我們可以使用ws[0]獲取字符“中”,使用ws[1]獲取字符“國”,使用ws[2]獲取字符“國”獲取字符 'a' 等,這個時候如果使用std::string,ws[0]拿出來的就是亂碼。
此外還受代碼頁編碼的影響(比如VS可以通過文件->高級保存選項->編碼 來更改當前代碼頁的編碼)。
下面是一些示例代碼,演示了如何進行正確的轉換,針對Windows平臺,官方提供了相應的系統Api(MultiByteToWideChar):
std::wstring Utf8ToUnicode(const std::string& str) {
int len = str.length();
if (0 == len)
return L"";
int nLength = MultiByteToWideChar(CP_UTF8, 0, str.c_str(), len, 0, 0);
std::wstring buf(nLength + 1, L'\0');
MultiByteToWideChar(CP_UTF8, 0, str.c_str(), len, &buf[0], nLength);
buf.resize(wcslen(buf.c_str()));
return buf;
}
std::string UnicodeToUtf8(const std::wstring& wstr) {
if (wstr.empty()) {
return std::string();
}
int size_needed = WideCharToMultiByte(CP_UTF8, 0, &wstr[0], static_cast<int>(wstr.size()), nullptr, 0, nullptr, nullptr);
std::string str_to(size_needed, 0);
WideCharToMultiByte(CP_UTF8, 0, &wstr[0], static_cast<int>(wstr.size()), &str_to[0], size_needed, nullptr, nullptr);
return str_to;
}
如果使用C++標準庫來實現,常規寫法可以參考下面:
#include <iostream>
#include <string>
#include <locale>
#include <codecvt>
// 從窄字符串到寬字符串的轉換
std::wstring narrowToWide(const std::string& narrowStr) {
try {
std::wstring_convert<std::codecvt_utf8<wchar_t>> converter;
return converter.from_bytes(narrowStr);
} catch (...) { // 如果傳進來的字符串不是utf8編碼的,這里會拋出std::range_error異常
return {};
}
}
// 從寬字符串到窄字符串的轉換
std::string wideToNarrow(const std::wstring& wideStr) {
try {
std::wstring_convert<std::codecvt_utf8<wchar_t>> converter;
return converter.to_bytes(wideStr);
} catch (...) {
return {};
}
}
//utf8字符串轉成string
std::string utf8ToString(const char8_t* str) {
std::wstring_convert<std::codecvt_utf8_utf16<char16_t>, char16_t> convert;
std::u16string u16str = convert.from_bytes(
reinterpret_cast<const char*>(str),
reinterpret_cast<const char*>(str + std::char_traits<char8_t>::length(str)));
return std::wstring_convert<std::codecvt_utf8_utf16<char16_t>, char16_t>{}.to_bytes(u16str);
}
int main(){
{
std::wstring wideStr = L"Hello, 你好!";
std::string narrowStr = wideToNarrow(wideStr);
std::wstring convertedWideStr = narrowToWide(narrowStr);
} {
//std::string narrowStr = "Hello, 你好!"; (1)
std::string narrowStr = utf8ToString(u8"Hello, 你好!"); //(2)
std::wstring wideStr = narrowToWide(narrowStr);
std::string convertedNarrowStr = wideToNarrow(wideStr);
}
return 0;
}
(1)首先std::string不理解編碼,在CPP官方手冊里面也解釋了,std::string處理字節的方式與所使用的編碼無關,如果用于處理多字節或可變長度字符的序列(例如 UTF-8),則此類的所有成員以及它的迭代器仍然以字節(而不是實際的編碼字符)為單位進行操作,如果用來處理包含中文的字符串就可能出現亂碼。這里直接將包含中文的字符串賦值給std::string,無法保證是UTF8編碼,進行轉換時會提示std::range_error異常;此外,std::wstring是會理解編碼的,其中的字符串通常使用 UTF-16 或 UTF-32 編碼,這取決于操作系統和編譯器的實現。
(2)這里由于使用u8""構造了UTF8編碼字符串,但是不能直接用來構造std::string,所以進行轉了下utf8ToString;
3.全局靜態對象
大家有沒有在工程代碼中發現有下面這種寫法,將常量字符串聲明為靜態全局的。
- static const std::string kVal="hahahhaha";
- static const std::wstring kxxConfigVal="hahahhaha";
優點:
- 可讀性好:使用有意義的變量名,可以清晰地表達變量的含義和用途,提高了代碼的可讀性。
- 安全性高:由于使用了 const 關鍵字,這個字符串變量是不可修改的,可以避免意外的修改和安全問題。
- 生命周期長:靜態變量的生命周期從程序啟動到結束,不受函數的調用和返回影響。
缺點:
- 構造開銷:靜態變量的初始化發生在程序啟動時也就是執行main()之前,會增加程序啟動的時間和資源消耗。大量的這種靜態全局對象,會拖慢程序啟動速度
- 靜態變量共享:靜態變量在整個程序中只有一份實例,可能會導致全局狀態共享和難以調試的問題。
此外,靜態變量的初始化順序可能會受到編譯單元(源文件)中其他靜態變量初始化順序的影響,因此在跨編譯單元的情況下,靜態變量的初始化順序可能是不確定的。
在實際編程中,還是不太建議使用全局靜態對象,建議的寫法:
要聲明全局的常量字符串,可以使用 const 關鍵字和 extern 關鍵字的組合:
// constants.h
extern const char* GLOBAL_STRING;
// constants.cpp
\#include "constants.h"
const char* GLOBAL_STRING = "Hello, world!";
constexpr char* kVal="hahhahah";
使用 constexpr 關鍵字來聲明全局的常量字符串:
// constants.h
constexpr const char* GLOBAL_STRING = "Hello, world!";
三、迭代器刪除
在處理緩存時,容器元素的增刪查改是很常見的,通過迭代器去刪除容器(vector/map/set/unordered_map/list)元素也是常有的,但這其中使用不當也會存在很多坑。
std::vector<int> numbers = { 88, 101, 56, 203, 72, 135 };
auto it = std::find_if(numbers.begin(), numbers.end(), [](int num) {
return num > 100 && num % 2 != 0;
});
vec.erase(it);
上面代碼,查找std::vector中大于 100 并且為奇數的整數并將其刪除。std::find_if 將從容器的開頭開始查找,直到找到滿足條件的元素或者遍歷完整個容器,并返回迭代器it,然后去刪除該元素。但是這里沒有判斷it為空的情況,直接就erase了,如果erase一個空的迭代器會引發crash。很多新手程序員會犯這樣的錯誤,隨時判空是個不錯的習慣。
刪除元素不得不講下std::remove 和 std::remove_if,用于從容器中移除指定的元素, 函數會將符合條件的元素移動到容器的末尾,并返回指向新的末尾位置之后的迭代器,最后使用容器的erase來擦除從新的末尾位置開始的元素。
std::vector<std::string> vecs = { "A", "", "B", "", "C", "hhhhh", "D" };
vecs.erase(std::remove(vecs.begin(), vecs.end(), ""), vecs.end());
// 移除所有偶數元素
vec.erase(std::remove_if(vec.begin(), vec.end(), [](int x) { return x % 2 == 0; }), vec.end());
這里的erase不用判空,其內部實現已經有判空處理。
_CONSTEXPR20 iterator erase(const_iterator _First, const_iterator _Last) noexcept(
is_nothrow_move_assignable_v<value_type>) /* strengthened */ {
const pointer _Firstptr = _First._Ptr;
const pointer _Lastptr = _Last._Ptr;
auto& _My_data = _Mypair._Myval2;
pointer& _Mylast = _My_data._Mylast;
// ....
if (_Firstptr != _Lastptr) { // something to do, invalidate iterators
_Orphan_range(_Firstptr, _Mylast);
const pointer _Newlast = _Move_unchecked(_Lastptr, _Mylast, _Firstptr);
_Destroy_range(_Newlast, _Mylast, _Getal());
_Mylast = _Newlast;
}
return iterator(_Firstptr, _STD addressof(_My_data));
}
此外,STL容器的刪除也要小心迭代器失效,先來看個vector、list、map刪除的例子:
// vector、list、map遍歷并刪除偶數元素
std::vector<int> elements = { 1, 2, 3, 4, 5 };
for (auto it = elements.begin(); it != elements.end();) {
if (*it % 2 == 0) {
elements.erase(it++);
} else {
it++;
}
}
// Error
std::list<int> cont{ 88, 101, 56, 203, 72, 135 };
for (auto it = cont.begin(); it != cont.end(); ) {
if (*it % 2 == 0) {
cont.erase(it++);
} else {
it++;
}
}
// Ok
std::map<int, std::string> myMap = { {1, "one"}, {2, "two"}, {3, "three"}, {4, "four"}, {5, "five"} };
// 遍歷并刪除鍵值對,刪除鍵為偶數的元素
for (auto it = myMap.begin(); it != myMap.end(); ) {
if (it->first % 2 == 0) {
myMap.erase(it++);
} else {
it++;
}
}
// Ok
上面幾類容器同樣的遍歷刪除元素,只有vector報錯crash了,map和list都能正常運行。其實vector調用erase()方法后,當前位置到容器末尾元素的所有迭代器全部失效了,以至于不能再使用。
迭代器的失效問題:對容器的操作影響了元素的存放位置,稱為迭代器失效。迭代器失效的情況:
- 當容器調用erase()方法后,當前位置到容器末尾元素的所有迭代器全部失效。
- 當容器調用insert()方法后,當前位置到容器末尾元素的所有迭代器全部失效。
- 如果容器擴容,在其他地方重新又開辟了一塊內存,原來容器底層的內存上所保存的迭代器全都失效。
迭代器失效有三種情況,由于底層的存儲數據結構,分三種情況:
- 序列式迭代器失效,序列式容器(std::vector和std::deque),其對應的數據結構分配在連續的內存中,對其中的迭代器進行insert和erase操作都會使得刪除點和插入點之后的元素挪位置,進而導致插入點和刪除掉之后的迭代器全部失效。可以利用erase迭代器接口返回的是下一個有效的迭代器。
- 鏈表式迭代器失效,鏈表式容器(std::list)使用鏈表進行數據存儲,插入或者刪除只會對當前的節點造成影響,不會影響其他的迭代器。可以利用erase迭代器接口返回的是下一個有效的迭代器,或者將當前的迭代器指向下一個erase(iter++)。
- 關聯式迭代器失效,關聯式容器,如map, set,multimap,multiset等,使用紅黑樹進行數據存儲,刪除當前的迭代器,僅會使當前的迭代器失效。erase迭代器的返回值為 void(C++11之前),可以采用erase(iter++)的方式進行刪除。值得一提的是,在最新的C++11標準中,已經新增了一個map::erase函數執行后會返回下一個元素的iterator,因此可以使用erase的返回值獲取下一個有效的迭代器。
在實現上有兩種模板,其一是通過 erase 獲得下一個有效的 iterator,使用于序列式迭代器和鏈表式迭代器(C++11開始關聯式迭代器也可以使用)
for (auto it = elements.begin(); it != elements.end(); ) {
if (ShouldDelete(*it)) {
it = elements.erase(it); // erase刪除元素,返回下一個迭代器
} else {
it++;
}
}
其二是,遞增當前迭代器,適用于鏈表式迭代器和關聯式迭代器。
for (auto it = elements.begin(); it != elements.end(); ) {
if (ShouldDelete(*it)) {
elements.erase(it++);
} else {
it++;
}
}
四、對象拷貝
在眾多編程語言中C++的優勢之一便是其高性能,可是開發者代碼寫得不好(比如:很多不必要的對象拷貝),直接會影響到代碼性能,接下來就講幾個常見的會引起無意義拷貝的場景。
1.for循環:
std::vector<std::string> vec;
for(std::string s: vec) {
}
// or
for(auto s: vec) {
}
這里每個string都會被拷貝一次,為避免無意義拷貝可以將其改成:
for(const auto& s: vec) 或者 for (const std::string& s: vec)
2.lambda捕獲
// 獲取對應消息類型的內容
std::string GetRichTextMessageXxxContent(const std::shared_ptr<model::Message>& message,
const std::map<model::MessageId, std::map<model::UserId, std::string>>& related_user_names,
const model::UserId& login_userid,
bool for_message_index) {
// ...
// 解析RichText內容
return DecodeRichTextMessage(message, [=](uint32_t item_type, const std::string& data) {
std::string output_text;
// ...
return output_text;
});
}
上述代碼用于解析獲取文本消息內容,涉及到富文本消息的解析和一些邏輯的計算,高頻調用,他在解析RichText內容的callback中直接簡單粗暴的按值捕獲了所有變量,將所有變量都拷貝了一份,這里造成不必要的性能損耗,尤其上面那個std::map。這里可以改成按引用來捕獲,規避不必要的拷貝。
lambda函數在捕獲時會將被捕獲對象拷貝,如果捕獲的對象很多或者很占內存,將會影響整體的性能,可以根據需求使用引用捕獲或者按需捕獲:
auto func = &a{};
auto func = a = std::move(a){}; (限C++14以后)
3.隱式類型轉換
std::map<int, std::string> myMap = {{1, "One"}, {2, "Two"}, {3, "Three"}};
for (const std::pair<int, std::string>& pair : myMap) {
//...
}
這里在遍歷關聯容器時,看著是const引用的,心想著不會發生拷貝,但是因為類型錯了還是會發生拷貝,std::map 中的鍵值對是以 std::pair<const Key, T> 的形式存儲的,其中key是常量。因此,在每次迭代時,會將當前鍵值對拷貝到臨時變量中。在處理大型容器或頻繁遍歷時,這種拷貝操作可能會產生一些性能開銷,所以在遍歷時推薦使用const auto&,也可以使用結構化綁定:for(const auto& [key, value]: map){} (限C++17后)
4.函數返回值優化
RVO是Return Value Optimization的縮寫,即返回值優化,NRVO就是具名的返回值優化,為RVO的一個變種,此特性從C++11開始支持。為了更清晰的了解編譯器的行為,這里實現了構造/析構及拷貝構造、賦值操作函數,如下:
class Widget {
public:
Widget() {
std::cout << "Widget: Constructor" << std::endl;
}
Widget(const Widget& other) {
name = other.name;
std::cout << "Widget: Copy construct" << std::endl;
}
Widget& operator=(const Widget& other) {
std::cout << "Widget: Assignment construct" << std::endl;
name = other.name;
return *this;
}
~Widget() {
std::cout << "Widget: Destructor" << std::endl;
}
public:
std::string name;
};
Widget GetMyWidget(int v) {
Widget w;
if (v % 2 == 0) {
w.name = 1;
return w;
} else {
return w;
}
}
int main(){
const Widget& w = GetMyWidget(2); // (1)
Widget w = GetMyWidget(2); // (2)
GetMyWidget(2); // (3)
return 0;
}
運行上面代碼,跑出的結果:
未優化:(msvc 2022, C++14)
Widget: Constructor
Widget: Copy construct
Widget: Destructor
Widget: Destructor
優化后:
Widget: Constructor
Widget: Destructor
針對上面(1)(2)(3)的調用,我之前也是有點迷惑,以為要減少拷貝必須得用常引用來接,但是發現編譯器進行返回值優化后(1)(2)(3)運行結果都是一樣的,也就是日常開發中,針對函數中返回的臨時對象,可以用對象的常引用或者新的一個對象來接,最后的影響其實可以忽略不計的。不過個人還是傾向于對象的常引用來接,一是出于沒有優化時(編譯器不支持或者不滿足RVO條件)可以減少一次拷貝,二是如果返回的是對象的引用時可以避免拷貝。但是也要注意不要返回臨時對象的引用。
// pb協議接口實現
inline const ::PB::XXXConfig& XXConfigRsp::config() const {
//...
}
void XXSettingView::SetSettingInfo(const PB::XXConfigRsp& rsp){
const auto config = rsp.config(); // 內部返回的是對象的引用,這里沒有引用來接導致不必要的拷貝
}
當遇到上面這種返回對象的引用時,外部最好也是用對象的引用來接,減少不必要的拷貝。
此外,如果Widget的拷貝賦值操作比較耗時,通常在使用函數返回這個類的一個對象時也是會有一定的講究的。
// style 1
Widget func(Args param);
// style 2
bool func(Widget* ptr, Args param);
上面的兩種方式都能達到同樣的目的,但直觀上的使用體驗的差別也是非常明顯的:
style 1只需要一行代碼,而style 2需要兩行代碼,可能大多數人直接無腦style 1
// style 1
Widget obj = func(params);
// style 2
Widget obj;
func(&obj, params);
但是,能達到同樣的目的,消耗的成本卻未必是一樣的,這取決于多個因素,比如編譯器支持的特性、C++語言標準的規范強制性等等。
看起來style 2雖然需要寫兩行代碼,但函數內部的成本卻是確定的,只會取決于你當前的編譯器,外部即使采用不同的編譯器進行函數調用,也并不會有多余的時間開銷和穩定性問題。使用style 1時,較復雜的函數實現可能并不會如你期望的使用RVO優化,如果編譯器進行RVO優化,使用style 1無疑是比較好的選擇。利用好編譯器RVO特性,也是能為程序帶來一定的性能提升。
5.函數傳參使用對象的引用
effective C++中也提到了:以pass-by-reference-to-const替換pass-by-value
指在函數參數傳遞時,將原本使用"pass-by-value"(按值傳遞)的方式改為使用 "pass-by-reference-to-const"(按常量引用傳遞)的方式。
在 "pass-by-value" 中,函數參數會創建一個副本,而在 "pass-by-reference-to-const" 中,函數參數會成為原始對象的一個引用,且為了避免修改原始對象,使用了常量引用。
通過使用 "pass-by-reference-to-const",可以避免在函數調用時進行對象的拷貝操作,從而提高程序的性能和效率;還可以避免對象被切割問題:當一個派生類對象以傳值的方式傳入一個函數,但是該函數的形參是基類,則只會調用基類的構造函數構造基類部分,派生類的新特性將會被切割。此外,使用常量引用還可以確保函數內部不會意外地修改原始對象的值。
五、std::shared_ptr線程安全
對shared_ptr相信大家都很熟悉,但是一提到是否線程安全,可能很多人心里就沒底了,借助本節,對shared_ptr線程安全方面的問題進行分析和解釋。shared_ptr的線程安全問題主要有兩種:1. 引用計數的加減操作是否線程安全; 2. shared_ptr修改指向時是否線程安全。
1.引用計數
shared_ptr中有兩個指針,一個指向所管理數據的地址,另一個指向執行控制塊的地址。
執行控制塊包括對關聯資源的引用計數以及弱引用計數等。在前面我們提到shared_ptr支持跨線程操作,引用計數變量是存儲在堆上的,那么在多線程的情況下,指向同一數據的多個shared_ptr在進行計數的++或--時是否線程安全呢?
引用計數在STL中的定義如下:
_Atomic_word _M_use_count; // #shared
_Atomic_word _M_weak_count; // #weak + (#shared != 0)
當對shared_ptr進行拷貝時,引入計數增加,實現如下:
template <>
inline bool _Sp_counted_base<_S_atomic>::_M_add_ref_lock_nothrow() noexcept {
// Perform lock-free add-if-not-zero operation.
_Atomic_word __count = _M_get_use_count();
do {
if (__count == 0) return false;
// Replace the current counter value with the old value + 1, as
// long as it's not changed meanwhile.
} while (!__atomic_compare_exchange_n(&_M_use_count, &__count, __count + 1, true, __ATOMIC_ACQ_REL,
__ATOMIC_RELAXED));
return true;
}
template <>
inline void _Sp_counted_base<_S_single>::_M_add_ref_copy() {
++_M_use_count;
}
對引用計數的增加主要有以下2種方法:_M_add_ref_copy函數,對_M_use_count + 1,是原子操作。_M_add_ref_lock函數,是調用__atomic_compare_exchange_n``實現的``,主要邏輯仍然是_M_use_count + 1,而該函數是線程安全的,和_M_add_ref_copy的區別是對不同_Lock_policy有不同的實現,包含直接加、原子操作加、加鎖。
因此我們可以得出結論:在多線程環境下,管理同一個數據的shared_ptr在進行計數的增加或減少的時候是線程安全的,這是一波原子操作。
2.修改指向
修改指向分為操作同一個shared_ptr對象和操作不同的shared_ptr對象兩種。
(1) 多線程代碼操作的是同一個shared_ptr的對象
比如std::thread的回調函數,是一個lambda表達式,其中引用捕獲了一個shared_ptr對象
shared_ptr<A> sp1 = make_shared<A>();
std::thread td([&sp1] () {....});
又或者通過回調函數的參數傳入的shared_ptr對象,參數類型是指針或引用:
`指針類型:void fn(shared_ptr<A>* sp) { ... }std::thread td(fn, &sp1);引用類型:void fn(shared_ptr<A>& sp) { ... }std::thread td(fn, std::ref(sp1));`
當你在多線程回調中修改shared_ptr指向的時候,這時候確實不是線程安全的。
void fn(shared_ptr<A>& sp) {
if (..) {
sp = other_sp;
} else if (...) {
sp = other_sp2;
}
}
shared _ptr內數據指針要修改指向,sp原先指向的引用計數的值要減去1,other_sp指向的引用計數值要加1。然而這幾步操作加起來并不是一個原子操作,如果多個線程都在修改sp的指向的時候,那么有可能會出問題。比如在導致計數在操作-1的時候,其內部的指向已經被其他線程修改過了,引用計數的異常會導致某個管理的對象被提前析構,后續在使用到該數據的時候觸發coredump。當然如果你沒有修改指向的時候,是沒有問題的。也就是:
- 同一個shared_ptr對象被多個線程同時讀是安全的
- 同一個shared_ptr對象被多個線程同時讀寫是不安全的
(2) 多線程代碼操作的不是同一個shared_ptr的對象
這里指的是管理的數據是同一份,而shared_ptr不是同一個對象,比如多線程回調的lambda是按值捕獲的對象。
std::thread td([sp1] () {....});
或者參數傳遞的shared_ptr是值傳遞,而非引用:
void fn(shared_ptr<A> sp) {
...
}
std::thread td(fn, sp1);
這時候每個線程內看到的sp,他們所管理的是同一份數據,用的是同一個引用計數。但是各自是不同的對象,當發生多線程中修改sp指向的操作的時候,是不會出現非預期的異常行為的。也就是說,如下操作是安全的:
void fn(shared_ptr<A> sp) {
if (..) {
sp = other_sp;
} else if (...) {
sp = other_sp2;
}
}
盡管前面我們提到了如果是按值捕獲(或傳參)的shared_ptr對象,那么該對象是線程安全的,然而話雖如此,但卻可能讓人誤入歧途。因為我們使用shared_ptr更多的是操作其中的數據,對齊管理的數據進行讀寫,盡管在按值捕獲的時候shared_ptr是線程安全的,我們不需要對此施加額外的同步操作(比如加解鎖),但是這并不意味著shared_ptr所管理的對象是線程安全的!請注意這是兩回事。
最后再來看下std官方手冊是怎么講的:
All member functions (including copy constructor and copy assignment) can be called by multiple threads on different instances of shared_ptr without additional synchronization even if these instances are copies and share ownership of the same object. If multiple threads of execution access the same instance of shared_ptr without synchronization and any of those accesses uses a non-const member function of shared_ptr then a data race will occur; the shared_ptr overloads of atomic functions can be used to prevent the data race.
這段話的意思是,shared_ptr 的所有成員函數(包括復制構造函數和復制賦值運算符)都可以由多個線程在不同的 shared_ptr 實例上調用,即使這些實例是副本并且共享同一個對象的所有權。如果多個執行線程在沒有同步的情況下訪問同一個 shared_ptr 實例,并且這些訪問中的任何一個使用了 shared_ptr 的非 const 成員函數,則會發生數據競爭;可以使用shared_ptr的原子函數重載來防止數據競爭。
我們可以得到下面的結論:
(1) 多線程環境中,對于持有相同裸指針的std::shared_ptr實例,所有成員函數的調用都是線程安全的。
- 當然,對于不同的裸指針的 std::shared_ptr 實例,更是線程安全的
- 這里的 “成員函數” 指的是 std::shared_ptr 的成員函數,比如 get ()、reset ()、operrator->()等
(2) 多線程環境中,對于同一個std::shared_ptr實例,只有訪問const的成員函數,才是線程安全的,對于非const成員函數,是非線程安全的,需要加鎖訪問。
首先來看一下 std::shared_ptr 的所有成員函數,只有前3個是 non-const 的,剩余的全是 const 的:
成員函數 | 是否const |
operator= | non-const |
reset | non-const |
swap | non-const |
get | const |
operator、operator-> | const |
operator | const |
use_count | const |
operator bool | const |
unique | const |
講了這么多,來個栗子實踐下:
ant::Promise<JsAPIResultCode, CefRefPtr<CefDictionaryValue>>
XXXHandler::OnOpenSelectContactH5(const JsAPIContext& context, std::shared_ptr<RequestType> arguments) {
ant::Promise<JsAPIResultCode, CefRefPtr<CefDictionaryValue>> promise;
base::GetUIThread()->PostTask(weak_lambda(this, [this, promise, context, arguments]() {
auto b_executed_flag = std::make_shared<std::atomic_bool>(false);
auto ext_param = xx::OpenWebViewWindow::OpenURLExtParam();
// ...
// SelectCorpGroupContact jsapi的回調
ext_param.select_group_contact_callback = [promise, b_executed_flag](
JsAPIResultCode resCode, CefRefPtr<CefDictionaryValue> res) mutable {
*b_executed_flag = true;
base::GetUIThread()->PostTask([promise, resCode, res]() {
promise.resolve(resCode, res);
});
};
// 窗口關閉回調
ext_param.dismiss_callback = [promise, b_executed_flag]() {
if (*b_executed_flag) {
return;
}
promise.resolve(JSAPI_RESULT_CANCEL, CefDictionaryValue::Create());
};
// ...
xx::OpenWebViewWindow::OpenURL(nullptr, url, false, ext_param);
}));
return promise;
}
該段代碼場景是一個Jsapi接口,在接口中打開另一個webview的選人窗口,選人窗口操作后或者關閉時都需要回調下,將結果返回jsapi。選人完畢確認后會回調select_group_contact_callback,同時關閉webview窗口還會回調dismiss_callback,這倆回調里面都會回包,這里還涉及多線程調用。這倆回調只能調用一個,為了能簡單達到這種效果,作者用std::shared_ptrstd::atomic_bool b_executed_flag來處理多線程同步,如果一個回調已執行就標記下,shared_ptr本身對引用計數的操作是線程安全的,通過原子變量std::atomic_bool來保證其管理的對象的線程安全。
六、std::map
// 定義數據緩存類
class DataCache {
private:
std::map<std::string, std::string> cache;
public:
void addData(const std::string& key, const std::string& value) {
cache[key] = value;
}
std::string getData(const std::string& key) {
return cache[key];
}
};
在上述示例中,簡單定義了個數據緩存類,使用 std::map作為數據緩存,然后提供addData添加數據到緩存,getData從map緩存中獲取數據。一切看起來毫無違和感,代碼跑起來也沒什么問題,但是如果使用沒有緩存的key去getData, 發現會往緩存里面新插入一條value為默認值的記錄。
需要注意的是,如果我們使用 [] 運算符訪問一個不存在的鍵,并且在插入新鍵值對時沒有指定默認值,那么新鍵值對的值將是未定義的。因此,在使用 [] 運算符訪問 std::map 中的元素時,應該始終確保該鍵已經存在或者在插入新鍵值對時指定了默認值。
void addData(const std::string& key, const std::string& value) {
if(key.empty()) return;
cache[key] = value;
}
std::string getData(const std::string& key) {
const auto iter = cache.find(key);
return iter != cache.end() ? iter->second : "";
}
七、sizeof & strlen
相信大家都有過這樣的經歷,在項目中使用系統API或者與某些公共庫編寫邏輯時,需要C++與C 字符串混寫甚至轉換,在處理字符串結構體的時候就免不了使用sizeof和strlen,這倆看著都有計算size的能力,有時候很容易搞混淆或者出錯。
- sizeof 是個操作符,可用于任何類型或變量,包括數組、結構體、指針等, 返回的是一個類型或變量所占用的字節數; 在編譯時求值,不會對表達式進行求值。
- strlen 是個函數,只能用于以 null 字符結尾的字符串,返回的是一個以 null 字符('\0')結尾的字符串的長度(不包括 null 字符本身),且在運行時才會計算字符串的長度。
需要注意的是,使用 sizeof 操作符計算數組長度時需要注意數組元素類型的大小。例如,對于一個 int 類型的數組,使用 sizeof 操作符計算其長度應該為 sizeof(array) / sizeof(int)。而對于一個字符數組,使用strlen函數計算其長度應該為 strlen(array)。
char str[] = "hello";
char *p = str;
此時,用sizeof(str)得到的是6,因為hello是5個字符,系統儲存的時候會在hello的末尾加上結束標識\0,一共為6個字符;
而sizeof(p)得到的卻是4,它求得的是指針變量p的長度,在32位機器上,一個地址都是32位,即4個字節。
- 用sizeof(p)得到的是1,因為p定義為char,相當于一個字符,所以只占一個字節。
- 用strlen(str),得到的會是5,因為strlen求得的長度不包括最后的\0。
- 用strlen(p),得到的是5,與strlen(str)等價。
上面的是sizeof和strlen的區別,也是指針字符串和數組字符串的區別。
const char* src = "hello world";
char* dest = NULL;
int len = strlen(src); // 這里很容易出錯,寫成sizeof(src)就是求指針的長度,即4
dest = (char*)malloc(len + 1); // 這里很容易出錯,寫成len
char* d = dest;
const char* s = &src[len - 1]; // 這里很容易出錯,寫成len
while (len-- != 0) {
*d++ = *s--;
}
*d = '\0'; // 這句很容易漏寫
printf("%sIn", dest);
free(dest);
八、std::async真的異步嗎?
std::async是C++11開始支持多線程時加入的同步多線程構造函數,其彌補了std::thread沒有返回值的問題,并加入了更多的特性,使得多線程更加靈活。
顧名思義,std::async是一個函數模板,它將函數或函數對象作為參數(稱為回調)并異步運行它們,最終返回一個std::future,它存儲std::async()執行的函數對象返回的值,為了從中獲取值,程序員需要調用其成員 future::get.
那std::async一定是異步執行嗎?先來看段代碼:
int calculate_sum(const std::vector<int>& numbers) {
std::cout << "Start Calculate..." << std::endl; // (4)
int sum = 0;
for (int num : numbers) {
sum += num;
}
return sum;
}
int main() {
std::vector<int> numbers = { 88, 101, 56, 203, 72, 135 };
std::future<int> future_sum = std::async(calculate_sum, numbers);
std::cout << "Other operations are in progress..." << std::endl; // (1)
int counter = 1;
while (counter <= 1000000000) {
counter++;
}
std::cout << "Other operations are completed." << std::endl; // (2)
// 等待異步任務完成并獲取結果
int sum = future_sum.get();
std::cout << "The calculation result is:" << sum << std::endl; // (3)
return 0;
}
直接運行上面的代碼,輸出結果如下:
Other operations are in progress...
Start Calculate...
Other operations are completed.
The calculation result is:655
執行完(1) 就去執行(4), 然后再(2)(3),說明這里是異步執行的。那可以認為async一定是異步的嗎?
如果改成std::async(std::launch::deferred, calculate_sum, numbers); 運行結果如下:
Other operations are in progress...
Other operations are completed.
Start Calculate...
The calculation result is:655
執行完(1) (2), 然后再(4)(3), 說明是真正調用std::future<>::get()才去執行的,如果沒有調用get,那么就一直不會執行。
std::async是否異步受參數控制的,其第一個參數是啟動策略,它控制 std::async 的異步行為。可以使用 3 種不同的啟動策略創建 std::async ,即:
- std::launch::async 它保證異步行為,即傳遞的函數將在單獨的線程中執行
- std::launch::deferred 非異步行為,即當其他線程將來調用get()來訪問共享狀態時,將調用函數
- std::launch::async | std::launch::deferred 它是默認行為。使用此啟動策略,它可以異步運行或不異步運行,具體取決于系統上的負載,但我們無法控制它
如果我們不指定啟動策略,其行為類似于std::launch::async | std::launch::deferred. 也就是不一定是異步的。
Effective Modern C++ 里面也提到了,如果異步執行是必須的,則指定std::launch::async策略。
九、內存泄漏?
對于這樣的一個函數:
void processwidget(std::shared_ptrpw, int);
如果使用以下方式調用,會有什么問題嗎?
processwidget(std::shared_ptr(new Widget), priority());
一眼看上去覺得沒啥問題,甚至可能新手C++開發者也會這么寫,其實上面調用可能會存在內存泄漏。
編譯器在生成對processWidget函數的調用之前,必須先解析其中的參數。processWidget函數接收兩個參數,分別是智能指針的構造函數和整型的函數priority()。在調用智能指針構造函數之前,編譯器必須先解析其中的new Widget語句。因此,解析該函數的參數分為三步:
(1) 調用priority();
(2) 執行new Widget.
(3) 調用std:shared_ptr構造函數
C++編譯器以什么樣的固定順序去完成上面的這些事情是未知的,不同的編譯器是有差異的。在C++中可以確定(2)一定先于(3)執行,因為new Widoet還要被傳遞作為std::shared_ptr構造函數的一個實參。然而,對于priority()的調用可以在第(1)、(2)、(3)步執行,假設編譯器選擇以(2)執行它,最終的操作次序如下: (1) 執行new Widget; (2) 調用priority(): (3)調用std::shared_ptr構造函數。但是,如果priority()函數拋出了異常,經由new Widget返回的指針尚未被智能指針管理,將會遺失導致內存泄漏。
解決方法: 使用一個單獨的語句來創建智能指針對象。
std::shared ptr<Widget> pw(new widget); // 放在單獨的語句中
processwidget(pw, priority()):
// or
processwidget(std::make_shared<Widget>(), priority());
編譯器是逐語句編譯的,通過使用一個單獨的語句來構造智能指針對象,編譯器就不會隨意改動解析順序,保證了生成的機器代碼順序是異常安全的。
總結:尤其是在跨平臺開發的時候更加要注意這類隱晦的異常問題,Effective C++中也提到了,要以獨立語句將new對象存儲于智能指針內。如果不這樣做,一旦異常被拋出,有可能導致難以察覺的內存泄漏。
十、const/constexpr
如果C++11中引入的新詞要評一個"最令人困惑"獎,那么constexprhen很有可能獲此殊榮。當它應用于對象時,其實就是一個加強版的const,但應用于函數時,卻有著相當不同的意義。在使用 C++ const和consterpx的時候,可能都會犯暈乎,那constexpr和 const都有什么區別,這節簡單梳理下。
1.const
const一般的用法就是修飾變量、引用、指針,修飾之后它們就變成了常量,需要注意的是const并未區分出編譯期常量和運行期常量,并且const只保證了運行時不直接被修改。
一般的情況,const 也就簡單這么用一下,const 放在左邊,表示常量:
- const int x = 100; // 常量
- const int& rx = x; // 常量引用
- const int* px = &x; // 常量指針
給變量加上const之后就成了“常量”,只能讀、不能修改,編譯器會檢查出所有對它的修改操作,發出警告,在編譯階段防止有意或者無意的修改。這樣一來,const常量用起來就相對安全一點。在設計函數的時候,將參數用 const 修飾的話,可以保證效率和安全。
除此之外,const 還能聲明在成員函數上,const 被放在了函數的后面,表示這個函數是一個“常量”,函數的執行過程是 const 的,不會修改成員變量。
此外,const還有下面這種與指針結合的比較繞的用法:
int a = 1;
const int b = 2;
const int* p = &a;
int const* p1 = &a;
// *p = 2; // error C3892: “p”: 不能給常量賦值
p = &b;
// *p1 = 3; // error C3892: “p1”: 不能給常量賦值
p1 = &b;
int* const p2 = &a;
//p2 = &b; // error C2440: “=”: 無法從“const int *”轉換為“int *const ”
*p2 = 5;
const int* const p3 = &a;
const int 與 int const并無很大區別,都表示: 指向常量的指針,可以修改指針本身,但不能通過指針修改所指向的值。
而對于int *const,則是表示:一個常量指針,可以修改所指向的值,但不能修改指針本身。
const int* const 表示一個不可修改的指針,既不能修改指針本身,也不能通過指針修改所指向的值。
總之,const默認與其左邊結合,當左邊沒有任何東西則與右邊結合。
2.constexpr
表面上看,constexpr不僅是const,而且在編譯期間就已知,這種說法并不全面,當它應用在函數上時,就跟它名字有點不一樣了。使用constexpr關鍵字可以將對象或函數定義為在編譯期間可求值的常量,這樣可以在編譯期間進行計算,避免了運行時的開銷。
constexpr對象 必須在編譯時就能確定其值,并且通常用于基本數據類型。例如:
- constexpr int MAX_SIZE = 100; // 定義一個編譯時整型常量
- constexpr double PI = 3.14159; // 定義一個編譯時雙精度浮點型常量
const和constexpr變量之間的主要區別在于變量的初始化,const可以推遲到運行時,constexpr變量必須在編譯時初始化。const 并未區分出編譯期常量和運行期常量,并且const只保證了運行時不直接被修改,而constexpr是限定在了編譯期常量。簡而言之,所有constexpr對象都是const對象,而并非所有的const對象都是constexpr對象。
- 當變量具有字面型別(literal type)(這樣的型別能夠持有編譯期可以決議的值)并已初始化時,可以使用constexpr來聲明該變量。如果初始化由構造函數執行,則必須將構造函數聲明為constexpr.
- 當滿足這兩個條件時,可以聲明引用constexpr:引用的對象由常量表達式初始化,并且在初始化期間調用的任何隱式轉換也是常量表達式。
constexpr變量或函數的所有聲明都必須具有constexpr說明符。
constexpr float x = 42.0;
constexpr float y{108};
constexpr float z = exp(5, 3);
constexpr int i; // Error! Not initialized
int j = 0;
constexpr int k = j + 1; //Error! j not a constant expression
constexpr函數 是指能夠在編譯期間計算結果的函數。它們的參數和返回值類型必須是字面值類型,并且函數體必須由單個返回語句組成。例如:
constexpr int square(int x) {
return x * x;
}
constexpr int result = square(5); // 在編譯期間計算結果,result 的值為 25
使用 constexpr 可以提高程序的性能和效率,因為它允許在編譯期間進行計算,避免了運行時的計算開銷。同時,constexpr 還可以用于指定數組的大小、模板參數等場景,提供更靈活的編程方式。
對constexpr函數的理解:
- constexpr函數可以用在要求編譯器常量的語境中。在這樣的語境中,如果你傳給constexpr函數的實參值是在編譯期已知的,則結果也會在編譯期間計算出來。如果任何一個實參值在編譯期間未知,則代碼將無法通過編譯。
- 在調用constexpr函數時,若傳入的值有一個或多個在編譯期間未知,則它的運作方式和普通函數無異,也就是它也是在運行期執行結果的計算。也就是說,如果一個函數執行的是同樣的操作,僅僅應用語境一個是要求編譯期常量,一個是用于所有其他值的話,那就不必寫兩個函數。constexpr函數就可以同時滿足需求。
constexpr int square(int x) {
return x * x;
}
比起非constexpr對象或constexpr函數而言,constexpr對象或是constexpr函數可以用在一個作用域更廣的語境中。只要有可能使用constexpr,就使用它吧。