C++中的主要问题之一是存在大量行为未定义或对程序员来说意外的构造。我们在使用静态分析器检查各种项目时经常会遇到这些问题。但正如我们所知,最佳做法是在编译阶段尽早检测错误。让我们来看看现代C++中的一些技术,这些技术不仅帮助编写简单明了的代码,还能使代码更加安全可靠。
1、什么是现代C++?“现代C++”这一术语在C++11发布后变得非常流行。那么它是什么意思呢?首先,现代C++是一套模式和惯用法,旨在消除老式“带类的C”中的缺点,特别是对于那些从C语言起步的C++程序员来说。C++11看起来更加简洁明了,这一点非常重要。
2、自动类型推断在C++中,引入了关键字auto和decltype。当然,你已经知道它们是如何工作的。
std::mapint,intm;autoit=(42);//C++98:std::mapint,int::iteratorit=(42);
这非常方便,可以缩短长类型,同时不影响代码的可读性。然而,这些关键字与模板一起变得相当广泛:使用auto和decltype不需要指定返回值的类型。
但让我们回到主题。这是一个64位错误的示例:
stringstr=..;unsignedn=("ABC");if(n!=string::npos)在64位应用程序中,std::string::npos的值大于UINT_MAX(无符号类型变量能够表示的最大值)。这看起来是一个auto可以解决的问题的例子:n变量的类型对我们来说并不重要,主要的是它能够容纳std::string::find的所有可能值。
事实上,如果我们使用auto重写这个示例,错误就会消失:
stringstr=..;auton=("ABC");if(n!=string::npos)但并非所有事情都这么简单。使用auto并不是万灵药,它的使用有很多陷阱。例如,你可以这样写代码:
auton=1024*1024*1024*5;char*buf=newchar[n];
auto无法解决整数溢出问题,而且分配的缓冲区内存会少于5GiB。
auto在处理一个非常常见的错误时也帮不上太大忙:写错的循环。让我们来看一个例子:
std::vectorintbigVector;for(unsignedi=0;();++i){}对于大型数组,这种循环会变成一个无限循环。这种错误在代码中并不少见:它们在非常罕见的情况下暴露出来,而这些情况通常没有测试。
我们可以使用auto重写这个代码片段吗?
std::vectorintbigVector;for(autoi=0;();++i){}不能。错误不仅仍然存在,而且变得更糟了。
在简单类型的情况下,auto的行为很糟糕。是的,在最简单的情况下(例如autox=y),它能正常工作,但一旦有额外的构造,行为可能变得更加不可预测。更糟糕的是,错误会变得更难发现,因为变量的类型一开始并不明显。幸运的是,这对于静态分析器来说不是问题:它们不会感到疲倦,也不会失去注意力。但对我们这些普通人来说,最好还是显式地指定类型。我们还可以通过其他方法避免窄化转换,但我们稍后会讨论这些方法。
3、危险的countof在C++中,“危险”的类型之一是数组。程序员经常在将数组传递给函数时忘记它是作为指针传递的,并尝试使用sizeof来计算元素的数量。
define_ARRAYSIZE(A)RTL_NUMBER_OF_V1(A)intGetAllNeighbors(constCCoreDispInfo*pDisp,intiNeighbors[512]){.if(nNeighbors_ARRAYSIZE(iNeighbors))iNeighbors[nNeighbors++]=pCorner-m_Neighbors[i];.}注意:这段代码摘自SourceEngineSDK。
PVS-Studio警告:V511sizeof()运算符返回的是指针的大小,而不是数组的大小,这在sizeof(iNeighbors)表达式中出现。Vrad_dlldisp_
这种混淆可能是因为在参数中指定了数组的大小:这个数字对编译器没有意义,仅仅是对程序员的提示。
问题在于这段代码被编译了,而程序员可能不知道其中存在问题。显而易见的解决方案是使用元编程:
templateclassT,size_tNconstexprsize_tcountof(constT(array)[N]){returnN;}countof(iNeighbors);//编译时错误如果传递给这个函数的不是数组,我们会得到编译错误。在C++17中,可以使用std::size。
在C++11中,std::extent函数被引入,但它不适合作为countof,因为它对不适当的类型返回0。
std::extentdecltype(iNeighbors)();//=0
你不仅会在countof中犯错,也可能在sizeof中出现错误:
VisitedLinkMaster::TableBuilder::TableBuilder(VisitedLinkMaster*master,constuint8salt[LINK_SALT_LENGTH]):master_(master),success_(true){fingerprints_.reserve(4096);memcpy(salt_,salt,sizeof(salt));}注意:这段代码摘自Chromium。
PVS-Studio警告:
V511sizeof()运算符返回的是指针的大小,而不是数组的大小,这在sizeof(salt)表达式中出现。browservisitedlink_
V512memcpy函数的调用将导致salt_缓冲区的下溢。browservisitedlink_
正如你所见,标准C++数组有很多问题。这就是为什么你应该使用std::array的原因:在现代C++中,它的API类似于std::vector和其他容器,并且在使用时更难出错。
voidFoo(std::arrayuint8,16array){();//=16}4、如何在一个简单的for中犯错误constintSerialWindow::kBaudrates[]={50,75,110,.};SerialWindow::SerialWindow():.{.for(inti=sizeof(kBaudrates)/sizeof(char*);--i=0;){message-AddInt32("baudrate",kBaudrateConstants[i]);.}}注:这段代码取自Haiku操作系统。
PVS-Studio警告:V706可疑的除法:sizeof(kBaudrates)/sizeof(char*)。kBaudrates数组中每个元素的大小与除数不相等。
我们在前面的章节中详细检查过这种错误:数组大小没有正确计算。我们可以通过使用std::size来轻松修复它:
constintSerialWindow::kBaudrates[]={50,75,110,.};SerialWindow::SerialWindow():.{.for(inti=std::size(kBaudrates);--i=0;){message-AddInt32("baudrate",kBaudrateConstants[i]);.}}但是有一个更好的方法。让我们再看一个片段。
inlinevoidCXmlReader::CXmlInputStream::UnsafePutCharsBack(constTCHAR*pChars,size_tnNumChars){if(nNumChars0){for(size_tnCharPos=nNumChars-1;nCharPos=0;--nCharPos)UnsafePutCharBack(pChars[nCharPos]);}}注:这段代码取自Shareaza。
PVS-Studio警告:V547表达式nCharPos=0始终为真。无符号类型的值总是大于等于0。
这是编写反向循环时的典型错误:程序员忘记了无符号类型的迭代器检查总是返回真。你可能会想,“怎么会这样?只有新手和学生才会犯这样的错误。我们专业人员不会。”不幸的是,这并不完全正确。当然,每个人都知道(unsigned=0)的结果为真。这样的错误通常在哪里出现?它们常常是在重构的过程中发生的。假设项目从32位平台迁移到64位。之前使用了int/unsigned进行索引,后来决定将它们替换为size_t/ptrdiff_t。但在某个片段中,他们不小心使用了无符号类型而不是有符号类型。
为了避免这种情况,你的代码中可以采取什么措施?有些人建议使用有符号类型,例如C#或Qt中的方式。也许,这是一种解决方案,但如果我们要处理大量数据,那么就无法避免使用size_t。有没有更安全的方式在C++中迭代数组?当然有。我们从最简单的方法开始:非成员函数。标准库中有用于处理集合、数组和initializer_list的标准函数,它们的原理应该对你来说很熟悉。
charbuf[4]={'a','b','c','d'};for(autoit=rbegin(buf);it!=r(buf);++it){std::cout*it;}很好,现在我们不需要记住直接循环和反向循环之间的区别了。也不必考虑我们使用的是简单数组还是数组——循环在任何情况下都会有效。使用迭代器是一种避免麻烦的好方法,但即便如此,有时也不够理想。最佳的做法是使用基于范围的for循环:
charbuf[4]={'a','b','c','d'};for(autoit:buf){std::coutit;}当然,基于范围的for循环也有一些缺陷:它不允许灵活地管理循环,如果需要对索引进行更复杂的操作,那么这种for循环帮助不大。但这种情况应该另行讨论。我们现在面对的是一个比较简单的情况:我们需要以反向顺序遍历元素。然而,在这个阶段,已经出现了一些困难。标准库中没有额外的类来支持基于范围的for循环。我们来看看如何实现它:
templatetypenameTstructreversed_wrapper{constT_v;reversed_wrapper(constTv):_v(v){}autobegin()-decltype(rbegin(_v)){returnrbegin(_v);}auto()-decltype(r(_v)){returnr(_v);}};templatetypenameTreversed_wrapperTreversed(constTv){returnreversed_wrapperT(v);}在C++14中,你可以通过去掉decltype来简化代码。你可以看到auto如何帮助你编写模板函数——reversed_wrapper将同时适用于数组和std::vector。
现在我们可以将代码片段重写如下:
charbuf[4]={'a','b','c','d'};for(autoit:reversed(buf)){std::coutit;}这段代码有什么好处呢?首先,它非常易于阅读。我们立即可以看到元素数组是以反向顺序排列的。其次,出错的可能性较小。第三,它适用于任何类型。这比之前的做法要好得多。
在Boost中,你可以使用boost::adaptors::reverse(arr)。
但让我们回到最初的例子。那里,数组是通过一对指针大小传递的。显然,我们的reversed方法对于这种情况是不适用的。我们应该怎么做?使用像span/array_view这样的类。在C++17中,我们有string_view,我建议使用它:
voidFoo(std::string_views);std::stringstr="abc";Foo(std::string_view("abc",3));Foo("abc");Foo(str);std::string_view不拥有字符串,实际上它是一个constchar*和长度的包装器。这就是为什么在代码示例中,字符串是通过值传递的,而不是通过引用传递的。string_view的一个关键特性是它与各种字符串表示方式的兼容性:constchar*、std::string和非空终止的constchar*。
因此,函数的形式如下:
inlinevoidCXmlReader::CXmlInputStream::UnsafePutCharsBack(std::wstring_viewchars){for(wchar_tch:reversed(chars))UnsafePutCharBack(ch);}在将值传递给函数时,需要记住string_view(constchar*)的构造函数是隐式的,因此我们可以像这样写:
Foo(pChars);
而不是这样:
Foo(wstring_view(pChars,nNumChars));
string_view指向的字符串不需要是以null结尾的,这个名字string_view::data就暗示了这一点。在使用string_view时必须记住这一点。当将其值传递给一个期望C字符串的cstdlib函数时,可能会出现未定义的行为。如果在大多数测试用例中使用的是std::string或以null结尾的字符串,这种问题可能会被忽略。
5、枚举让我们暂时抛开C++,来看看老旧的C语言。那么C语言的安全性如何呢?毕竟,它没有隐式构造函数调用和操作符,也没有类型转换的问题,也没有各种类型字符串的问题。在实际应用中,错误往往发生在最简单的构造中:最复杂的构造因为引起怀疑而经过仔细审查和调试。与此同时,程序员们往往会忘记检查简单的构造。以下是一个来自C语言的危险结构的例子:
enumiscsi_param{.ISCSI_PARAM_CONN_PORT,ISCSI_PARAM_CONN_ADDRESS,.};enumiscsi_host_param{.ISCSI_HOST_PARAM_IPADDRESS,.};intiscsi_conn_get_addr_param(.,enumiscsi_paramparam,.){.switch(param){caseISCSI_PARAM_CONN_ADDRESS:caseISCSI_HOST_PARAM_IPADDRESS:.}returnlen;}这是一个Linux内核的例子。PVS-Studio警告:V556不同枚举类型的值进行比较:switch(ENUM_TYPE_A){caseENUM_TYPE_B:…}。第3501行。
请注意switch-case中的值:其中一个命名常量来自不同的枚举。在原始代码中,当然有更多的代码和可能的值,错误并不那么明显。这是因为枚举的类型松散——它们可能会隐式地转换为int,这留下了很多错误的空间。
在C++11中,您可以并且应该使用enumclass:这样的技巧在那里行不通,错误会在编译阶段显示出来。结果,以下代码无法编译,这正是我们所需要的:
enumclassISCSI_PARAM{.CONN_PORT,CONN_ADDRESS,.};enumclassISCSI_HOST{.PARAM_IPADDRESS,.};intiscsi_conn_get_addr_param(.,ISCSI_PARAMparam,.){.switch(param){caseISCSI_PARAM::CONN_ADDRESS:caseISCSI_HOST::PARAM_IPADDRESS:.}returnlen;}以下片段与枚举不完全相关,但具有类似的症状:
voidadns__querys_tcp(.){if(!(errno==EAGAIN||EWOULDBLOCK||errno==EINTR||errno==ENOSPC||errno==ENOBUFS||errno==ENOMEM)){}注意:这段代码来自ReactOS。
是的,errno的值被声明为宏,这在C++中是不好的做法(在C中也是如此),但即使程序员使用了枚举,也不会更容易解决这个问题。失去的比较在枚举中不会显现出来(特别是在宏的情况下)。同时,使用enumclass不会允许这种情况,因为不会有隐式转换为bool。
6、构造函数中的初始化回到原生C++的问题。其中一个问题在于当需要在多个构造函数中以相同的方式初始化对象时会显现出来。一个简单的情况是:有一个类,两个构造函数,其中一个调用另一个。这看起来很合逻辑:将公共代码放入一个单独的方法中——没有人喜欢重复代码。那么,陷阱是什么呢?
Guess::Guess(){language_str=DEFAULT_LANGUAGE;country_str=DEFAULT_COUNTRY;encoding_str=DEFAULT_ENCODING;}Guess::Guess(constchar*guess_str){Guess();.}注意:这段代码来自LibreOffice。
PVS-Studio警告:V603对象被创建了但没有被使用。如果您希望调用构造函数,应使用this-Guess::Guess(.)。第56行。
问题在于构造函数调用的语法。经常会忘记这一点,程序员会创建一个额外的类实例,然后立即销毁它。也就是说,原始实例的初始化没有发生。当然,有很多方法可以解决这个问题。例如,我们可以通过this显式调用构造函数,或者将所有内容放入一个单独的函数中:
Guess::Guess(constchar*guess_str){this-Guess();.}Guess::Guess(constchar*guess_str){Init();.}顺便提一下,显式地重复调用构造函数,例如通过this,是一种危险的做法,我们需要了解发生了什么。使用Init()的变体更好且更清晰。
但在这里,最好使用构造函数的委托。这样我们可以以以下方式显式地从一个构造函数调用另一个构造函数:
Guess::Guess(constchar*guess_str):Guess(){.}这种构造函数有几个限制。首先:委托构造函数对对象的初始化负有全部责任。也就是说,它无法在初始化列表中初始化另一个类字段:
Guess::Guess(constchar*guess_str):Guess(),m_member(42){.}当然,我们必须确保委托不会创建循环,否则将无法退出。遗憾的是,这段代码会被编译:
Guess::Guess(constchar*guess_str):Guess(std::string(guess_str)){.}Guess::Guess(std::stringguess_str):Guess(guess__str()){.}7、关于虚函数虚函数会带来潜在的问题:派生类中函数签名错误很容易发生,结果可能不会重写函数,而是声明了一个新函数。我们来看以下例子:
classBase{virtualvoidFoo(intx);}classDerived:publicBase{voidFoo(intx,inta=1);}通过指向Base的指针或引用无法调用Derived::Foo。不过这是一个简单的例子,你可能会说没人会犯这样的错误。通常,人们会以以下方式出错:
classDBClientBase:.{public:virtualauto_ptrDBClientCursorquery(conststringns,Queryquery,intnToReturn=0intnToSkip=0,constBSONObj*fieldsToReturn=0,intqueryOptions=0,intbatchSize=0);};classDBDirectClient:publicDBClientBase{public:virtualauto_ptrDBClientCursorquery(conststringns,Queryquery,intnToReturn=0,intnToSkip=0,constBSONObj*fieldsToReturn=0,intqueryOptions=0);};注意:这段代码取自MongoDB。
PVS-Studio警告:V762请检查虚函数参数。请查看派生类DBDirectClient和基类DBClientBase中函数query的第七个参数。文件第61行。
函数的参数很多,并且在继承类的函数中没有最后一个参数。这些是不同的、不相关的函数。这样的错误经常发生在具有默认值的参数上。
在下面的代码片段中,情况会更加复杂。如果编译为32位代码,这段代码会正常工作,但在64位版本中则无法正常工作。最初,在基类中,参数是DWORD类型,但后来被更正为DWORD_PTR。同时,继承类中的参数没有相应地更改。愿不眠的夜晚、调试和咖啡长存!
classCWnd:publicCCmdTarget{.virtualvoidWinHelp(DWORD_PTRdwData,UINTnCmd=HELP_CONTEXT);.};classCFrameWnd:publicCWnd{.};classCFrameWndEx:publicCFrameWnd{.virtualvoidWinHelp(DWORDdwData,UINTnCmd=HELP_CONTEXT);.};你可以以更为离奇的方式犯错,比如忘记函数的const限定符或参数的const限定符,或者忽略基类函数是否为虚函数,或者混淆有符号与无符号类型。
在C++中,添加了几个关键字来规范虚函数的重写。override关键字将大有帮助。这样,代码将无法编译。
classDBDirectClient:publicDBClientBase{public:virtualauto_ptrDBClientCursorquery(conststringns,Queryquery,intnToReturn=0,intnToSkip=0,constBSONObj*fieldsToReturn=0,intqueryOptions=0)override;};8、NULLvsnullptr使用NULL来表示空指针可能会导致许多意想不到的情况。NULL实际上是一个普通的宏,它展开成0,其类型为int。这就是为什么在以下示例中选择第二个函数的原因:
voidFoo(intx,inty,constchar*name);voidFoo(intx,inty,intResourceID);Foo(1,2,NULL);
虽然原因很清楚,但这种情况很不符合逻辑。这也是为什么需要nullptr的原因,nullptr具有自己的类型nullptr_t。这就是为什么在现代C++中不能使用NULL(更不要说0)。
另一个例子是:NULL可以与其他整数类型进行比较。假设有一个WinAPI函数返回HRESULT。该类型与指针无关,因此与NULL的比较毫无意义。nullptr通过引发编译错误来强调这一点,而NULL则不会:
if(WinApiFoo(a,b)!=NULL)//不好if(WinApiFoo(a,b)!=nullptr)//好,编译错误9、va_arg
在某些情况下,需要传递不确定数量的参数。一个典型的例子是格式化输入/输出函数。虽然可以编写成不需要可变数量参数的方式,但我认为没有理由放弃这种语法,因为它更方便且更易于阅读。旧的C++标准提供了什么?它们建议使用va_list。这会带来哪些问题?对于这种参数,传递错误类型的参数并不那么容易,也有可能完全没有传递参数。让我们仔细看看这些片段。
typedefstd::wstringstring16;constbase::string16relaunch_flags()const;intRelaunchChrome(constDelegateExecuteOperationoperation){AtlTrace("Relaunching[%ls]withflags[%s]\n",().c_str(),_flags());.}注意:这段代码取自Chromium。
PVS-Studio警告:V510AtlTrace函数不应接收类类型变量作为第三个实际参数。delegate_第96行
程序员想打印std::wstring字符串,但忘记调用c_str()方法。因此,wstring类型在函数中会被解释为constwchar_t*。当然,这样做是无济于事的。
cairo_status_t_cairo_win32_print_gdi_error(constchar*context){.fwprintf(stderr,L"%s:%S",context,(wchar_t*)lpMsgBuf);.}注意:这段代码取自Cairo。
PVS-Studio警告:V576格式不正确。请检查fwprintf函数的第三个实际参数。期望的是指向wchar_t类型符号的字符串指针。第130行
在这段代码中,程序员混淆了字符串格式说明符。问题在于,在VisualC++中,wchar_t*和%S都期望wprintf的%s格式说明符。值得注意的是,这些错误出现在用于错误输出或调试信息的字符串中——这些是较少见的情况,因此被忽略了。
staticvoidGetNameForFile(constchar*baseFileName,constuint32fileIdx,charoutputName[512]){assert(baseFileName!=NULL);sprintf(outputName,"%s_%d",baseFileName,fileIdx);}注意:这段代码取自CryEngine3SDK。
PVS-Studio警告:V576格式不正确。请检查sprintf函数的第四个实际参数。期望的是有符号整数类型参数。第66行
整数类型也很容易混淆,尤其是当它们的大小依赖于平台时。然而,在这里情况要简单得多:有符号和无符号类型被混淆了。大的数字将被打印为负数。
ReadAndDumpLargeSttb(cb,err)intcb;interr;{.printf("\n-%dstringswereread,""%dwereexpected(decimalnumbers)-\n");.}注意:这段代码取自。
PVS-Studio警告:V576格式不正确。调用printf函数时实际参数的数量不匹配。期望:3个。实际:1个。第498行
这个字符串预期有三个参数,但实际没有提供。可能程序员打算打印栈上的数据,但我们不能假设栈上有什么内容。显然,我们需要显式地传递这些参数。
BOOLCALLBACKEnumPickIconResourceProc(HMODULEhModule,LPCWSTRlpszType,LPWSTRlpszName,LONG_PTRlParam){.swprintf(szName,L"%u",lpszName);.}注意:这段代码取自ReactOS。
PVS-Studio警告:V576格式不正确。请检查swprintf函数的第三个实际参数。打印指针的值应该使用%p。第66行
这是一个64位错误的示例。指针的大小依赖于架构,使用%u来打印指针是不合适的。我们应该使用什么呢?分析器提示正确的格式符是%p。如果指针用于调试时打印,这是非常有用的。如果之后尝试从缓冲区读取它并使用它,那就更有意思了。
对于具有可变参数的函数,几乎一切都有可能出错!你无法检查参数的类型或数量。稍有偏差,就会出现未定义行为。
幸运的是,现在有更可靠的替代方案。首先,变参模板就是其中之一。借助变参模板,我们可以在编译期间获取所有传递类型的信息,并按需使用它。举个例子,我们可以使用一个更安全的printf:
voidprintf(constchar*s){std::couts;}templatetypenameT,typenameArgsvoidprintf(constchar*s,Tvalue,Argsargs){while(s*s){if(*s=='%'*++s!='%'){std::coutvalue;returnprintf(++s,args);}std::cout*s++;}}当然,这只是一个示例:在实际应用中,其使用意义不大。但在变参模板的情况下,你的限制仅限于你的想象力,而不是语言特性。
另一种可以用来传递可变参数的构造是std::initializer_list。它不允许传递不同类型的参数,但如果这足够,你可以这样使用:
voidFoo(std::initializer_listinta);Foo({1,2,3,4,5});它也很方便遍历,因为我们可以使用begin、和范围for循环。
10、窄化转换窄化转换给程序员的生活带来了很多麻烦。特别是当迁移到64位架构变得更加必要时,这种问题显得尤为突出。理想情况下,代码中应该只有正确的类型。但是实际情况往往不是如此:程序员常常使用各种“黑科技”和一些奇特的方法来存储指针。找到这些代码片段需要消耗大量的时间和精力:
char*ptr=;intn=(int)ptr;.ptr=(char*)n;
不过,我们暂时不讨论64位错误。这儿有一个更简单的例子:程序员想要找出两个整数值的比例。这样做的代码如下:
virtualintGetMappingWidth()=0;virtualintGetMappingHeight()=0;voidCDetailObjectSystem::LevelInitPreEntity(){.floatflRatio=pMat-GetMappingWidth()/pMat-GetMappingHeight();.}注意:这段代码取自SourceEngineSDK。
PVS-Studio警告:V636表达式被隐式地从‘int’类型转换为‘float’类型。请考虑使用显式类型转换以避免丢失小数部分。示例:doubleA=(double)(X)/Y;。客户端(HL2)
不幸的是,无法完全防止这种错误——总会有某种方式隐式地将一种类型转换为另一种类型。但是好消息是,C++11引入的新初始化方法具有一个很好的特性:它禁止狭义转换。在这种代码中,错误将在编译阶段被发现,可以轻松地加以修正。
floatflRatio{pMat-GetMappingWidth()/pMat-GetMappingHeight()};11、没有消息就是好消息管理资源和内存的错误方式有很多种。现代语言在工作便利性方面有很高的要求。现代C++也不落后,提供了多种自动资源控制工具。尽管这些错误在动态分析中很常见,但有些问题可以通过静态分析来发现。以下是其中一些问题的示例:
voidAccessibleContainsAccessible(.){auto_ptrVARIANTchild_array(newVARIANT[child_count]);}注意:这段代码取自Chromium。
PVS-Studio警告:V554错误使用了auto_ptr。使用new[]分配的内存将通过delete清理。interactive_ui_testsaccessibility_win_
当然,智能指针的理念并不新鲜:例如,曾经有一个类std::auto_ptr。我使用过去式谈论它,因为它在C++11中被声明为弃用,并在C++17中被移除。在这个代码片段中,错误是由于错误使用了auto_ptr,该类没有数组的专门化,因此将调用标准的delete,而不是delete[]。unique_ptr替代了auto_ptr,并且它对数组有专门化支持,还可以传递一个删除器函数对象,该对象将在delete代替调用,并且完全支持移动语义。看起来这里似乎没有什么问题。
voidtext_editor::_m_draw_string(.)const{.std::unique_ptrunsignedpxbuf_ptr(newunsigned[len]);.}注意:这段代码取自nana。
PVS-Studio警告:V554错误使用了unique_ptr。使用new[]分配的内存将通过delete清理。text_
结果发现,其实你也会犯同样的错误。是的,只需写unique_ptrunsigned[],错误就会消失,但代码在这种形式下仍然能编译。因此,这种方式也可能出错,实践表明,只要可能,人们就会这样做。这段代码就是证明。因此,使用unique_ptr管理数组时,务必小心:比想象中更容易出错。也许使用std::vector会更符合现代C++的规范?
我们来看另一个事故类型。
templateclassTOpenGLStagestaticFStringGetShaderStageSource(TOpenGLStage*Shader){.ANSICHAR*Code=newANSICHAR[Len+1];glGetShaderSource(Shaders[i],Len+1,Len,Code);Source+=Code;deleteCode;.}注意:这段代码取自UnrealEngine4。
PVS-Studio警告:V611内存是使用newT[]操作符分配的,但却使用delete操作符释放。请考虑检查这段代码。最好使用delete[]Code;。
没有智能指针时,同样的错误也很容易出现:使用new[]分配的内存通过delete释放。
boolCxImage::LayerCreate(int32_tposition){.CxImage**ptmp=newCxImage*[+1];.free(ptmp);.}注意:这段代码取自CxImage。
PVS-Studio警告:V611内存是使用new操作符分配的,但却使用free函数释放。请考虑检查ptmp变量背后的操作逻辑。
在这个片段中,malloc/free和new/delete被混用。这可能发生在重构过程中:C语言的函数需要被替换,结果导致了未定义行为。
intsettings_proc_language_packs(.){.if(mem_files){mem_files=0;sys_mem_free(mem_files);}.}注意:这段代码取自FennecMedia。
PVS-Studio警告:V575空指针被传递给free函数。检查第一个参数。
这是一个更有趣的例子。有一种做法是在释放内存后将指针置为零。程序员有时甚至会为此编写特殊的宏。从某种程度上来说,这是一个很好的技术:你可以防止对同一块内存的再次释放。但是在这里,表达式的顺序被搞错了,因此free得到了一个空指针(这一点被分析器注意到了)。
ETOOLS_APIint__stdcallogg_enc(.){format=open_audio_file(in,enc_opts);if(!format){fclose(in);return0;};out=fopen(out_fn,"wb");if(out==NULL){fclose(out);return0;}}但这个问题不仅仅涉及内存管理,还涉及资源管理。例如,你可能忘记关闭文件,如上面的代码片段所示。在这两种情况下,RAII关键字概念都适用。这一概念也支持智能指针。结合移动语义,RAII有助于避免许多与内存泄漏相关的bugs。以这种风格编写的代码可以更直观地识别资源所有权。
作为一个小例子,我将提供一个基于unique_ptr的FILE封装器:
autodeleter=[](FILE*f){fclose(f);};std::unique_ptrFILE,decltype(deleter)p(fopen("1.txt","w"),deleter);尽管如此,你可能会希望有一个更具函数式的封装来处理文件(具有更易读的语法)。值得记住的是,在C++17中,将添加一个用于处理文件系统的API——std::filesystem。但是,如果你对这个决定不满意,并且希望使用fread/fwrite而不是i/o流,你可以从unique_ptr中获得一些灵感,编写自己的File类,这样可以根据你的个人需求进行优化,使其更方便、可读和安全。
结果是什么呢?现代C++提供了许多工具,帮助你更安全地编写代码。许多用于编译时评估和检查的构造也已出现。你可以切换到更方便的内存和资源管理模型。
但没有任何技术或编程范式可以完全保护你免于错误。与功能的增加相伴随,C++也会引入新的bugs,这些bugs可能只有C++特有。这就是为什么我们不能单纯依赖一种方法:我们应该始终结合代码审查、优质代码和良好的工具;这些可以帮助节省你的时间和精力,这些时间和精力可以用在更好的地方。





