-
-
[翻译]即使是C程序员也会喜欢的五个C++特性
-
发表于: 2024-7-22 17:44 2663
-
我一直认为C++很糟糕,而且糟糕透顶。过去20年来,我一直非常直言不讳地表达这种观点,几乎所有参与Windows驱动开发并略微阅读过NTDEV的人都听过我的论述。
我并非孤军奋战。事实上,像Mr. Penguin Pants和Elon Musk这样的人物也赞同我的观点。Ken Thompson和Rob Pike也似乎同意。
但是…… 我想我已经有所改变了。随着时间的推移,C++也有了很大的变化。C++ 11之前的版本与之后的版本非常不同。现在我们有了被称为“现代C++”的C++类型——如果你对这个概念不熟悉,MSDN上有一个很好的描述。
由于之前我极度激烈地反对C++,现在我有点不情愿地承认,也许现代C++有一些特性并不差。事实上,我认为即使是最热情的C语言程序员也会发现这些特性既有用,而且更重要的是,它们有助于构建高质量的Windows驱动程序。
在这篇文章中,我将介绍一些我们在OSR的驱动开发中经常使用的C++特性。你会发现我们并没有完全“接受”C++的世界;但是,随着C++的发展,我们也随之改变,我们已经走得很远,不再像以前那样“如果你在我们的驱动程序中加入任何C++代码,你将被绞死、开膛破肚”。
C++在Windows驱动程序中的限制
在我们开始之前,我应该指出,现代C++中有很多优秀且有用的特性无法在内核模式下使用。这是因为Windows内核模式不支持C++异常处理。C++异常处理与我们常用的内核模式结构化异常处理完全不同。由于这个限制,大多数C++标准库(std)部分都无法使用,包括一些最令人愉悦和最有用的特性,例如容器(例如 std::vector)和字符串处理。这也意味着你不能使用ATL或STL——它们在很大程度上已经被std取代了。除非你使用ATL来编写COM程序。如果你确实使用了,那么…… 我收回上句话。
所以,在提到了我们不能使用的一些酷炫的东西之后,让我们看看五个我们在Windows内核模式驱动程序中使用的最喜欢的C++结构。
#1: 强类型检查
是的,我意识到这不是现代C++的创新。但它仍然是C++比纯C语言的巨大优势。几乎没有人会反对强类型检查,它在C++中是固有的,是一个好东西。在我从事这项业务的过去一百万年里,我继承了无数最初用“纯”C语言编写的驱动程序(通常是客户编写的)。我可以诚实地说,当我将这些文件重命名为CPP扩展并重新构建它们时,我从未,也从来没有,没有发现一个有意义的类型错误。我认为这足以说明强类型检查的价值。
#2: 使用constexpr代替#define
我希望世界上已经没有人再认为在C/C++中使用预处理宏是“有问题的”了。回顾一下:古老的#define在编译时执行文本替换。这会导致各种意想不到的问题,因为#define没有类型安全,传递给#define的参数可能会被多次计算,等等。我并不是说你永远不应该使用#define。我只是说你应该在你的工具箱中没有其他工具能做到的时候才使用它。
通常情况下,constexpr可以直接替换“简单”的#define,比如定义位掩码和寄存器位模式时。在这种情况下,主要区别在于constexpr有一个类型。Visual Studio甚至会建议你将基本 #define更改为constexpr,并会提供转换选项(图 1)。
图1 – Visual Studio建议
我看到的唯一一个让VS按照它自己的方式处理你的代码的缺点是,它使用auto作为类型……这可能不是你想要的(图 2)。
图2 – VS使用auto作为类型来进行修正
我个人更喜欢自己指定类型。毕竟,我知道我将在哪里、何时以及如何使用这个定义(图 3)。
图3 – 我个人在这种情况下不喜欢使用auto
你也可以/应该在很多“类似函数”的#define宏中使用constexpr。例如,图4中显示的经典宏很容易转换为constexpr。这让你可以用一个函数的等价物来替换宏,这个函数的结果在编译时就可以得到。虽然VS不会用可爱的小圆点来建议这种转换,但如果你要求它,它会为你进行转换。它会尽力使用auto类型,包括对任何参数的数据类型进行模板化——想象我在这里翻白眼。我说,只要给参数指定一个特定的类型,然后就完事了(图 5)。我们以后再讨论auto是好是坏,以及何时使用它。
图4 – 适宜转换为 constexpr
图5 – 现在在编译阶段是常量
#3: 基于范围的循环
这是一个我并不每天都用到的功能,但当我需要它的时候,我就会想“它怎么以前没有出现”。我非常喜欢基于范围的for循环。
请考虑以下代码片段,请忽略(拜托)这种逐字符搜索是否明智,以及它实际上并没有做任何事情…毕竟,它只是出现在一篇文章中的一个例子:
constexpr int MAX_STRING_SIZE = 55; WCHAR stringBuffer[MAX_STRING_SIZE]; // Later in the code… for(int i = 0; i< MAX_STRING_SIZE; i++) { if(stringBuffer[i] == L'\\') { break; } }
即使是上面展示的这段代码,也比我通常看到这类代码的写法好多了…那种写法没有使用 MAX_STRING_SIZE,而是多次使用魔数常量“55”。真是令人讨厌。
总之,这种写法容易出错,而且即使使用符号常量,也很难保持一致。假设你的存储定义没有直接紧挨着你使用该存储的地方,你就不得不来回跳转,在声明和for循环之间切换。“stringBuffer的大小是MAX_STRING_SIZE还是MAX_BUFFER_SIZE或者 MAX_STRING_STORAGE?”当你快速浏览代码时,这些都是会导致错误的事情。我们都见过。
为了避免处理这些乱七八糟的东西,我们可以使用基于范围的for循环。就像这样:
constexpr int MAX_STRING_SIZE = 55; WCHAR stringBuffer[MAX_STRING_SIZE]; // Later in the code… for (WCHAR thisChar : stringBuffer) { if(thisChar == L'\\') { break; } }
我的天哪!这难道不是简洁明了多了吗?我认为是的。编译器知道数组stringBuffer的大小,使用基于范围的for循环,我们让编译器为我们处理它。因此,当你决定更改字符串缓冲区的大小,你只需要…更改字符串缓冲区的大小。代码会自动处理好。它不依赖于使用正确的定义。而且代码中也不可能出现定义错误。
#4: 有限的auto使用
我是一个非常喜欢用清晰、简洁、易于维护的方式编写代码的人。我不喜欢“聪明”的代码;我倾向于过度文档化,有时甚至会达到“用英语第二次编写我的C代码”的程度(我承认这很不明智,但我仍在从这种毛病中恢复过来)。总之,我对清晰度的追求意味着我自然地倾向于在大多数地方避免使用auto,即使你应该足够聪明,能够直觉地推断出变量的类型。因此,例如,虽然大多数现代C++粉丝可能会用上面的基于范围的for循环编写代码:
for (auto thisChar : stringBuffer) {
我几乎总是选择具体和清晰的写法:
for (WCHAR thisChar : stringBuffer) {
不过,有一个情况是我很喜欢使用auto的。那就是在赋值语句中,右侧有一个(非常大的)强制转换。例如,考虑以下常见情况,我们在I/O完成例程中将WDFCONTEXT强制转换为 WDFDEVICE的Context:
VOID BasicUsbEvtRequestReadCompletionRoutine(WDFREQUEST Request, WDFIOTARGET Target, PWDF_REQUEST_COMPLETION_PARAMS Params, WDFCONTEXT Context) { PBASICUSB_DEVICE_CONTEXT devContext = (PBASICUSB_DEVICE_CONTEXT)Context; // rest of completion routine..
我认为在组合的声明和赋值语句的左侧和右侧都完全指定数据类型,即使是最基本的说明性,也毫无意义。在这种情况下,我可以通过以下方式节省一些输入:
VOID BasicUsbEvtRequestReadCompletionRoutine(WDFREQUEST Request, WDFIOTARGET Target, PWDF_REQUEST_COMPLETION_PARAMS Params, WDFCONTEXT Context) { auto * devContext = (PBASICUSB_DEVICE_CONTEXT)Context; // rest of completion routine..
顺便说一下,请注意,我在上面使用了“auto *”,即使单独使用auto也能正常工作。你可以说我强迫症,但我喜欢明确表示我声明的是一个指针。此外,clang-tidy告诉我我应该这样做,我有什么理由反驳?实际上,这是一个我们将在另一篇文章中讨论的话题。
#5: 一些RAII模式
让我们先忽略RAII代表什么,好吗?RAII模式是在对象(例如类)实例化时自动初始化,并在对象不再需要时自动销毁的模式。现在,C语言的开发者们,别担心……我不会在这里用面向对象编程来吓唬你们。至少,不会太多。
我倾向于在我的驱动程序中使用RAII模式,当我要执行相当复杂的初始化并且该初始化不能失败,或者当我需要在完成对象后执行一些特定的或复杂的销毁操作时。
让我举一个来自我们项目的例子,来说明我发现RAII模式特别有帮助的地方。我们有一个驱动程序,需要检查发送给我们请求的用户访问权限。这涉及获取 SECURITY_SUBJECT_CONTEXT,锁定它,进行检查,然后(在退出函数之前)解锁并释放 SECURITY_SUBJECT_CONTEXT。
这是一个相当长的函数,我们需要在其中进行此检查,并且有许多事情会导致我们过早地退出函数。代码简直是一团糟。直到我创建了一个RAII类,它在实例化时获取并锁定 SECURITY_SUBJECT_CONTEXT,然后在函数退出时自动解锁并释放它。您可以在下面的图 6 中看到该类的代码。
class OSRAuthSecurityContext { public: SECURITY_SUBJECT_CONTEXT SubjectContext{}; PEPROCESS Process; PACCESS_TOKEN PrimaryToken; explicit OSRAuthSecurityContext(const WDFREQUEST & Request) { PIRP irp; irp = WdfRequestWdmGetIrp(Request); Process = IoGetRequestorProcess(irp); if (Process == nullptr) { Process = IoGetCurrentProcess(); } SeCaptureSubjectContextEx(nullptr, Process, &SubjectContext); SeLockSubjectContext(&SubjectContext); PrimaryToken = SubjectContext.PrimaryToken; } ~OSRAuthSecurityContext() { SeUnlockSubjectContext(&SubjectContext); SeReleaseSubjectContext(&SubjectContext); } // // Sigh... the Rule of Five requires these // OSRAuthSecurityContext(const OSRAuthSecurityContext&) = delete; // copy constructor OSRAuthSecurityContext & operator=(const OSRAuthSecurityContext&) = delete; // copy assignment OSRAuthSecurityContext(OSRAuthSecurityContext&&) = delete; // move constructor OSRAuthSecurityContext & operator=(OSRAuthSecurityContext&&) = delete; // move assignment };
图 6 – SUBJECT_SECURITY_CONTEXT的RAII类
要利用这个RAII对象,我们只需要在栈上创建一个实例,就像你在图7中看到的那样。然后你就可以像预期的那样使用这个类。
图 7 – 实例化RAII类
当然,到目前为止,这里没有任何东西是你不能用一个辅助函数来完成的,这个函数接收(或返回)指向某种“安全上下文”结构的指针,并使用传入的请求对其进行初始化。魔法出现在函数退出时,如图 8 所示。
图 8 – RAII进行清理
在我们使用OSRAuthSecurityContext的函数结束时,对象的析构函数会自动完成清理工作。因此,安全上下文始终被正确地解锁和释放。没有泄漏的可能性。我们唯一的问题是我们需要确保我们记录了正在发生的事情,以便那些可能毫无戒备的维护人员在之后接手。
我不知道你感觉如何,但我认为这既“奇妙”又“神奇”。再说一次,这不是我需要在每个我编写的驱动程序中都做的事情。但当我需要它的时候,很高兴能够使用这项功能。
#6(免费福利!)默认参数
我承诺过给你介绍五项功能。但是,由于我从一项(强类型检查)开始,而它已经广为人知且没有争议,所以我会额外赠送一项非常特殊的额外功能:默认参数。
默认参数可以使编写某些函数变得非常容易。以图 9 中所示的示例为例。再次,请先放下你对是否真的想将此函数放入代码中的判断……这仅仅是一个例子。
图 9 – 带可选参数的示例
在图9中,我们有一个函数,它接受三个参数。最后一个参数CharacterToUse是可选的。如果在调用函数时没有提供它,将使用默认值(示例中为0x00)。然后,你可以像图10所示那样,使用或不使用该最终可选参数来调用此函数。
图 10 – 调用带可选参数的函数
大多数现代C++特性(就像C++标准库中绝大多数特性一样)主要适用于用户模式代码。但我希望你已经看到,有一些现代C++特性可以使你的编码生活更轻松,甚至可以使你的驱动程序更可靠。
值得注意的是,还有很多我喜欢并且在驱动程序中起作用的C++特性,但我没有提到。例如,unique_ptr、std::tuple、<algorithm>中的许多函数,甚至是一些lambda表达式的用法。我梦想着有一天能够使用std::vector和std::map。
我希望你在自己的驱动程序项目中尝试一些你之前没有用过的C++特性。如果你尝试了,请到NTDEV上告诉我们你的体验。
OSR感谢C/C++专家Giovanni Dicanio抽出时间技术审核了这篇文章,这样我们就不会暴露自己为C++业余爱好者。你可以在他的博客https://blogs.msmvps.com/gdicanio/上找到 Gio。Gio是几门优秀的C/C++相关课程的作者,你可以在Pluralsight上找到。别忘了,如果你有MSVC订阅,你可能也获得了Pluralsight课程的免费访问权限!
[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课
赞赏
- [翻译]即使是C程序员也会喜欢的五个C++特性 2664
- [翻译]标准和隔离型微过滤驱动介绍 10238
- [分享]汇编习题集 2429
- [下载]intel 8086 手册 2605