要性能,更要安全
Rust和C++是两门比较流行的系统级开发语言。多年来,业界对C++的关注主要是在性能上,我们也不断地听到来自客户和安全研究员的反馈:他们希望C++应该在语言层面有更多的安全编码准则。
在安全编程这个方面来说,C++经常被认为落后于Rust。
借鉴于Rust在安全编码方面的特性,我们在Visual Studio 2019 v16.7的C++ Core Check中新增了四条编码安全准则。让我们来瞧瞧。
switch语句没有default标签
Rust中的模式匹配结构类似于C++中的switch语言结构。它们的主要差异在于,Rust要求开发者覆盖所有的模式匹配可能性,可以通过为每个模式编写一个显式的处理器,或者添加一个默认的处理器(如果其他所有的模式都不匹配的话)。
举个例子,下面的Rust代码将不会通过编译,因为它缺少默认的处理器。
这是一个简洁的安全特性,因为它可以防止这种很容易发生但又不那么容易捕获的编程错误。
如果switch语句中使用的是枚举类型并且不是每个枚举值都进行了判断,则Visual Studio会警告开发者并发出C4061和C4062。但是,对于其他其他类型,例如整型,则没有这个警告。
这次的版本我们引入了一个安全编码准则:对于非枚举类型(例如char, int),如果switch语句中没有default处理标签,Visual Studio将发出警告。可以在项目的规则设置中选择一下三种不同的规则然后进行代码分析。
- > C++ Core Check Style Rules
- > C++ Core Check Rules
- > Microsoft All Rules
下面我们来使用C++来重写上面Rust的例子。
如果我们将default标签去掉,则Visual Studio会给出如下的警告:
switch语句中的隐式跳转(Unannotated fallthrough)
关于Rust中的模式匹配的另外一个限制是:它们不支持在case语句中隐式跳转。而在C++中,下面的代码能完美的通过编译器的检查。
上面的C++代码开起来非常合理,但是在case语句中进行隐式的跳转很容易成为程序的Bug。举个例子,如果开发者忘记在each(food)调用后添加break语句,则代码还是会通过编译,但是运行的结果却大不一样。如果工程的规模十分庞大,则对于这类的Bug将很难追踪。
幸运的是,C++17 添加了[[fallthrough]]这样的标注,主要目的就是在不同的case语句中进行隐式跳转,这样的话,在上面的例子中,开发者就可以使用这个标注来向编译器表明他的确希望执行这种行为。
在Visual Studio 2019 v16.7中,如果代码中没有使用[[fallthrough]]标注的情况下出现了隐式跳转,则编译器会给出C26819警告。这条规则在Visual Studio执行代码分析时会默认启用。
为了解决上面的警告,可以在case语句中添加[[fallthrough]]标注,如下图所示:
昂贵的拷贝操作
Rust和C++中一个主要区别是,Rust默认采用移动(move)语义,而不是拷贝(copy)。
举个例子:
这意味着,当你确实需要拷贝语义的时候,需要使用显式的拷贝语句,如下图所示:
C++就不同了,它默认是拷贝语义。通常来说,这也不算什么大问题,但是有时这可能导致某些Bug。一个经常发生的例子是使用range-for语句的时候,让我们来看一下这个例子:
在上面的代码中,在vector中的每个原始被在每次迭代循环中被拷贝到p里。如果元素是一个大型结构,则拷贝操作将会十分昂贵,而且这种情况还不太容易看出来。
为了避免这种不必要的拷贝,我们在C++ Core Check中添加了一条的编码准则,建议开发者移除这种拷贝操作,如下图所示:
以下是判断某个拷贝操作是否有必要的方法:
如果类型的大小大于平台相关指针大小的两倍,并且该类型不是智能指针或gsl::span, gsl::string_span或std::string_view之一,则该拷贝被认为是不必要的。这意味着对于较小的数据类型(例如整型),不会触发该警告。对于较大的类型,例如上面示例中的Person类型,该拷贝操作被认为是昂贵(不必要)的,编译器将发出警告。
关于这条规则的最后一点是,如果循环体中的变量是mutated,则警告也不会触发,如下图所示:
如果使用的容器不是const类型,则可以通过修改对象为引用类型来避免不必要的拷贝。
但是,这样修改会导致一个新的副作用。因此,这个警告仅建议将循环变量标记为const 引用,如果无法合法地将循环变量标记为const类型,则这个警告不会触发。
此编码准则默认启用。
auto类型变量的拷贝
最后一个检查规则是有关auto类型变量的拷贝操作的。
考虑下面的Rust代码,其中为分配了引用的变量进行类型解析。
由于Rust的要求,在大多数情况下,复制必须是显式的,因此在上面的例子中,password类型在分配了immutable引用后会自动解析为immutable引用,并且不会执行昂贵的拷贝操作。
另一方面,考虑以下C++代码:
在上面的代码中,即使getPassword的返回类型是对字符串的const引用,password的类型也会被解析为std::string。结果是,PasswordManager::password的内容被复制到本地变量password中。
下面用一个返回指针的函数作为对比:
在分配引用和指向标记为auto的变量的指针之间的行为差异是不明显的,从而可能导致不必要的拷贝和意外拷贝。
为了防止由于此行为而导致的错误,检查器检查从引用到标记为auto的变量的所有初始化实例。如果使用与范围检查相同的试探法将生成的拷贝操作视为昂贵,则检查器会发出警告,建议将变量标记为const引用类型。
并且与范围检查一样,只要无法将变量合法地标记为const,就不会发出此警告。
另一个不会发出警告的情况是,无论何时从临时对象派生引用。在这种情况下,一旦临时文件被销毁,使用const auto引用将导致对已销毁临时变量的”悬挂”引用。
此编码准则默认启用。
总结
能看(写)到这里,我觉得也应该是个汉子了吧。
有些编码准则(例如声明变量时必初始化),最好能成为你的肌肉记忆,当写出某种代码结构的时候,是你的肌肉,而不是大脑,来完成安全编码原则。
最后
Microsoft Visual C++团队的博客是我非常喜欢的博客之一,里面有很多关于Visual C++的知识和最新开发进展。大浪淘沙,如果你对Visual C++这门古老的技术还是那么感兴趣,则可以经常去他们那(或者我这)逛逛。