现代C++语言核心特性解析

978-7-115-56417-7
作者: 谢丙堃
译者:
编辑: 陈聪聪
分类: C++

图书目录:

详情

本书是一本C++进阶图书,全书分为42章,深入探讨了从C++11到C++20引入的核心特性。书中不仅通过大量的实例代码讲解特性的概念和语法,还从编译器的角度分析特性的实现原理,书中还穿插了C++标准委员会制定特性标准时的一些小故事,帮助读者知其然也知其所以然。 本书适合因为工作需要学习C++新特性的C++从业者,同样也适合对C++新特性非常感兴趣的C++爱好者。而对于C++初学者来说,也有必要在理解C++基础知识后,通过本书来领略C++的另外一道风景。

图书摘要

版权信息

书名:现代C++语言核心特性解析

ISBN:978-7-115-56417-7

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。


著    谢丙堃

责任编辑 陈聪聪

人民邮电出版社出版发行  北京市丰台区成寿寺路11号

邮编 100164  电子邮件 315@ptpress.com.cn

网址 http://www.ptpress.com.cn

读者服务热线:(010)81055410

反盗版热线:(010)81055315


本书是一本C++进阶图书,全书分为42章,深入探讨了从C++11到C++20引入的核心特性。书中不仅通过大量的实例代码讲解特性的概念和语法,还从编译器的角度分析特性的实现原理,书中还穿插了C++标准委员会制定特性标准时的一些小故事,帮助读者知其然也知其所以然。

本书适合因为工作需要学习C++新特性的C++从业者,同样也适合对C++新特性非常感兴趣的C++爱好者。此外,具备C++基础知识的C++初学者也可以通过本书领略C++的另外一道风景。


在现代计算机的历史中,剑桥大学有着很重要的地位。1949年5月6日,剑桥大学制造的EDSAC计算机成功运行,成为世界上第一台具有完整功能的存储程序计算机。EDSAC是由剑桥大学数学实验室设计的,核心人物是莫里斯·威尔克斯(Maurice Wilkes)(1913—2010)。

1951年,爱迪生-韦斯利出版社(Addison-Wesley)出版了一本名为《为电子数字计算机准备程序》的书,书中介绍了如何为EDSAC计算机编写软件,这本书开创了一个新的出版领域,是出版历史中最早的软件编程图书。这本书的第一作者便是莫里斯·威尔克斯,第二作者是EDSAC团队的另一个成员戴维·惠勒(David J. Wheeler)(1927—2004)。

1970年,剑桥大学数学实验室改名为计算机实验室。

1975年,一个来自丹麦的年轻人申请到剑桥大学读博士,面试他的便是莫里斯·威尔克斯和罗杰·尼达姆(Roger Needham)。罗杰于1962年加入剑桥大学数学实验室,后来成为微软欧洲研究院的首任院长。

今天回想起来,1975年的这次面试可谓阵容强大,两位面试官一位是EDSAC的总设计师,一位是后来的研究院院长。

两位资深的面试官轮番提问,一个问题接着一个问题,让被面试者难以应付,有点焦头烂额。不过虽然面试过程很痛苦,但是结果却非常让人愉快,被面试的年轻人通过了面试。这个年轻人便是今天被尊称为C++之父的本贾尼·斯特劳斯特卢普(Bjarne Stroustrup)先生。本贾尼出生于1950年,25岁时就已经在丹麦的奥尔胡斯大学获得了硕士学位。这次面试让他得到了到现代计算机的摇篮之一继续学习的机会,也让他满足了女朋友的心愿。在到剑桥面试之前,本贾尼已经拿到了一所大学的邀约(offer),但他的女朋友说:“如果你能拿到剑桥大学的邀约,你应该选择剑桥。”

获得剑桥大学的学习机会,实现了女朋友的愿望,让本贾尼也很高兴。更重要的是,指导本贾尼博士学业的导师便是EDSAC的设计者之一戴维·惠勒。

多年之后在本贾尼获得计算机历史博物馆的院士荣誉后接受采访时,他仍清楚地记得第一次到惠勒办公室时的情景。本贾尼坐下来后,想听听导师安排自己做什么。没想到,惠勒提出了一个问题:“你知道读博士和读硕士的差别吗?”

本贾尼回答道:“不知道。”

惠勒说:“如果一定需要我告诉你应该做什么,那么你就是来读硕士。”

本贾尼明白了,导师是让他自己寻找研究方向。于是本贾尼花了一年时间来寻找研究方向,经过大量的调查和分析,最后选择了分布式系统。

1979年,本贾尼在剑桥大学拿到了博士学位。经过一番努力,他最终获得了到大洋彼岸的贝尔实验室工作的机会。

于是本贾尼先生带着妻子和女儿从英国到了美国。贝尔实验室位于美国新泽西州的默里山。在本贾尼到达前,那里已经因为发明了UNIX和C语言而名扬天下。

到贝尔实验室报到后,本贾尼找到自己的主管,坐下来,想听听领导安排自己做什么。领导的指示非常简单:“做点有趣的东西。”

回想起当年在剑桥第一次接受惠勒导师指导的经历,本贾尼对这个回答已经不惊异了。而且感到非常高兴,因为可以按照自己的想法大干一场。

做什么呢?本贾尼在做博士研究时,使用了一种名叫Simula的语言,它的最大特点就是“面向对象”,可以非常直观地表达现实世界,代码很优雅。但相对于贝尔实验室里流行的C语言来说,Simula的效率不够高。一个伟大的想法浮现在本贾尼的脑海里,那就是做一种新的编程语言,它既有C的高效性,又有Simula的自然和优雅。

想好了就动手,本贾尼把自己的新语言临时取名为“带有类的C”(C with Classes),开始改造编译器。

开发一种新的编程语言是一项巨大的工程,定义语法、开发编译器、编写用户手册等。在这个过程中,本贾尼给自己的新语言取了一个简单的名字:C++。

经过近5年的工作,1984年,C++语言的参考手册在贝尔实验室内部发布了。

1985年,C++的商业版本对外发布,C++开始了走向世界的步伐。

我在20世纪90年代读大学时,专业课程里安排的编程语言有FORTRAN、C以及汇编语言,没有C++。但是在图书馆里,我找到了介绍C++的书。更重要的是,当年流行的Borland C++ 3.1集成开发环境里大量使用了C++语言,最著名的就是宝蓝(Borland)公司开发的窗口库OWL(Object Windows Library)。于是我开始自学C++语言,并且使用C++语言编写了一些程序,包括我的毕业设计程序。

2005年,在上海的C++大会上,第一次见到本贾尼先生,近距离聆听了他关于C++的演讲。从那以后,多次与本贾尼先生见面,与他谈论的话题也逐渐增多。

2019年11月,本贾尼先生亲临C++大会会场,演讲间隙与很多与会者微笑合影。特别是在与本贾尼的座谈结束后,很多人走到本贾尼身边,请求合影。本贾尼先生有求必应,座谈大约12点结束,我上了个卫生间回来,合影仍在继续。根据主办方的安排,这天的午餐是所有讲师与本贾尼先生共进午餐,因为合影,午餐被推迟了十几分钟。餐厅在5楼,午餐后有演讲或者想听演讲的讲师离开了,本贾尼先生继续在餐厅,一边喝茶,一边聊天,我与他聊到13点多后,因为有事也到4楼会场了。大约14点左右,我在会场侧面的卫生间门口,又见到本贾尼先生,他被一位同行拦住,请求合影。就是在这样“人生有三急”的情况下,本贾尼先生还是非常配合地与那位同行来了个二人合照。我当时真是佩服本贾尼先生的平易和温和。

从2010年起,C++语言走上了快车道,在过去10年间发布了4个版本,大刀阔斧地引入了很多新的特征。在C++11开始的4个已发布版本中,C++引入了100多个新特征。这么多新特征让很多人感觉C++仿佛成了一门新的语言。于是便有了现代C++的说法。

与经典C++相比,现代C++的学习难度也比较大。这意味着对于一些老的C++程序员,学习现代C++也是有挑战的。如何快速掌握现代C++呢?

在2008年《软件调试》第1版出版后,我在高端调试网站举办了一个书友活动,在那次活动中,一个年轻帅气的年轻人给我留下了深刻的印象,他风华正茂,目光炯炯有神。他就是谢丙堃,当时在武汉大学读书。

去年年底,丙堃发了一份书稿给我,是关于现代C++的,我翻看了一下,书中选取了现代C++的40多个特征,每个特征一章,从多个角度解读这个特征。可贵的是,书中不仅有代码示例,结合实际代码来说理,还有作者的很多感悟和经验分享。

现代社会中,每个人都忙忙碌碌,特别是程序员群体,大多忙得像个陀螺。人生就在这样的忙碌中一天天过去。偶尔的闲暇也往往被各种游戏和刷屏占据,顾不上思考人生的方向和怎么实现目标。丙堃能在工作之余,坚持3年之久,日积月累,沉淀下这样一份书稿,真是难能可贵,钦佩之余,略缀数语于书前,聊表寸心。

张银奎   

2021年3月于盛格塾


近10年来C++的更新可谓是非常频繁,这让我在2017年时感受到了一丝不安。那个时候我发现在开源平台上已经有很多项目采用C++11和C++14标准编写,其中不乏一些知名的代码库,而公司里所用的编译环境还没有完全支持C++11,也很少有人真正了解过C++11的新特性。这带来一个很严重的问题,公司项目依赖的代码库更新到最新标准以后,我们将难以在一时之间维护甚至阅读它们,因为C++之父曾经说过“These enhancements are sufficient to make C++11 feel like a new language.”,他认为新特性的增强足以使C++11感觉像是一种新语言。可见即使是掌握C++11标准也需要将其当作一门新语言去学习,更何况当时的情况是C++17标准对C++又一次进行了大幅更新,很多原来的理解已经不准确甚至是不正确的了。尽管如此,我当时却没办法找到一本深入探讨C++11~C++17最新语言特性的书,在互联网上也只能找到零散的资料,并且大多数还是英文的。于是我产生了自己动手写一本深入探讨 C++最新语言特性的图书的想法。事实证明,我的担忧是有必要的。到目前为止已经有越来越多的项目开始迁移到新的C++标准,例如LLVM(C++14)、thrust(C++17)等,C++正在进入一个全新的时代,作为程序员的我们必须与时俱进地学习这些新特性来确保我们的技术不会过时。

本书的内容编排是理论结合实践,涵盖了C++11~C++20全部新增的语言核心特性,本书既能当作一本教材让读者可以由浅入深、由基础特性到高级特性来掌握新增特性的原理和用法,也能作为一本“字典”让读者有针对性地查阅单个特性。

本书分为两个部分,第一部分(第1~34章)是讲解基础特性,这部分内容在编程过程中会经常用到,具体如下。

第1章介绍C++11~C++20新增的基础类型,包括新的long long整型和多个新字符类型。

第2章介绍内联和嵌套命名空间,通过本章读者将学到如何在外部无感知的情况下切换命名空间。

第3章探讨了新特性中的重点auto占位符,探究它的推导规则,在lambda表达式中的应用,这将会是读者在现今C++中用到最多的关键字。

第4章探讨了decltype说明符,同样阐述了其推导规则,并将decltype(auto)和auto做了详细比较,有助于读者厘清两者的区别。

第5章介绍了函数返回类型后置特性,读者可以通过这种函数声明方式让编译器自动推导返回类型。

第6章深入探讨了右值引用,该特性是所有新特性中最难理解的特性之一。本章一步一步引导读者理解右值引用的含义和用途,并介绍其在移动语义中发挥的重要作用。另外还深入介绍了值类别,包括泛左值、纯右值和将亡值。

第7章介绍了lambda表达式特性,C++11~C++20逐步递进地讨论了lambda表达式功能的增强,包括基础语法、广义捕获、泛型lambda表达式以及如何在lambda表达式中使用模板语法。

第8章介绍了新的类成员的初始化方法,并且阐述了该方法与初始化列表的区别和优势。

第9章探究了列表初始化,该特性为初始化容器类型的对象提供了方便。本章详细描述了其工作原理并且演示了如何让自定义容器支持列表初始化。

第10章介绍了指定默认和删除函数的方法,读者通过本章可以学到如何通过指定默认函数强制编译器生成函数,以及删除特定函数让编译器无法自动生成。

第11章介绍非受限联合类型,该特性可以解决联合类型在C++中缺乏实用性的问题,通过非受限联合类型可以在联合类型中声明非平凡类型成员。

第12章和第13章介绍了委托构造函数和继承构造函数,它们都是为了解决C++类中构造函数代码冗余的问题。

第14章探究了强枚举类型,强枚举类型解决了普通枚举类型中几个一直被诟病的问题,包括类型检查不严格、底层类型定义不清晰等。

第15章详细探讨了扩展的聚合类型,阐明聚合类型的新定义,指出该新定义下过去代码中可能出现的问题。

第16章介绍了override和final说明符,说明了重写、重载和隐藏的区别,读者可以通过这两个说明符将虚函数重写的语法检查工作交给编译器。

第17章深入探讨了基于范围的for循环,该特性能简化枚举容器中元素的代码,除了描述该特性的使用方法,本章还说明了for循环的实现原理,并且实现了一个支持基于范围的for循环容器例子。

第18章介绍了支持初始化语句的if和switch,使用该特性可以将变量的初始化以及条件判断统一到相同的作用域。

第19章介绍了static_assert关键字,读者可以通过本章了解如何通过static_assert让代码在编译阶段就找到错误。

第20章深入探讨了结构化绑定的使用方式、规则和原理,通过本章,读者将学会如何让C++像Python一样返回多个值,如何让一个第三方类型支持结构化绑定语法。

第21章介绍了noexcept关键字,描述了noexcept相对于throw()的优势,并且探讨了noexcept在作为运算符时如何为移动构造函数提供异常控制的支持。

第22章讨论了类型别名和别名模板,读者通过本章将学会通过using关键字定义类型别名,并且掌握别名模板为后续模板的相关特性打下基础。

第23章介绍了指针字面量nullptr,讨论了nullptr对比0作为空指针常量的优势。

第24章探究了三向比较特性,阐述了三向比较运算符的语法,返回类型特征以及自动生成其他比较运算符的规则。

第25章介绍了线程局部存储,读者可以从本章了解到编译器和操作系统支持线程局部存储的方法,以及线程局部存储解决了哪些问题。

第26章介绍了扩展的inline说明符特性,该特性解决了类的非常量静态成员变量声明必须和定义分开的问题。

第27章深入探究了常量表达式。本章有一定难度,不仅介绍了使用constexpr定义常量表达式函数、构造函数,并且分不同时期的标准探讨了使常量表达式成立的规则的变化,另外还讨论了constexpr在if控制结构、lambda表达式和虚函数中的应用。

第28章讨论了表达式求值顺序的问题,新特性解决了C++17之前C++标准没有对表达式求值顺序做严格规定的问题。

第29章讨论了新标准对字面量的优化,其中集中描述了新标准对二进制整数、十六进制浮点、原生字符串字面量的支持,另外还详细介绍了如何实现自定义字面量。

第30章深入探讨了alignas和alignof关键字。本章从CPU的角度出发讨论了数据对齐对于程序运行效率的重要性,进而说明如何使用新标准提供的方式完成数据对齐,最后用实例证明了数据对齐对性能的影响。

第31章介绍了属性说明符和标准属性,通过本章读者将学会使用属性的方法,了解指定属性的规则,并且能充分理解C++11到C++20中的9个标准属性。

第32章介绍了新增预处理器和宏。本章展示了使用__has_include预处理器判断是否包含头文件的方法,并且介绍了一系列的特性测试宏,使用它们可以判断编译器对某个特性的支持程度。

第33章深入探讨了协程的使用方法和原理,从如何简单地借助标准库使用协程入手,分别诠释了co_await、co_yield和co_return运算符原理,并且展示了如何自定义一个支持协程的类型。

第34章是一些其他基础特性的优化,虽然这些特性比较简短不成体系但是却也相当重要,比如返回值优化,允许数组转换为未知范围的数组等。

从第35章开始进入本书的第二部分,第二部分主要探讨的是模板相关的新特性,具体如下。

第35章深入讨论了可变参数模板。本章中除了介绍可变参数模板的基本语法,还深入讨论了形参包展开的各种场景,展示了使用可变参数模板进行模板元编程的方法,最后探讨了C++17中折叠表达式的语法和规则。

第36章介绍了新标准对typename的优化,新标准明确指明了可以省略typename的场景,并且让模板参数支持使用typename。

第37章集中介绍了新标准对模板参数的改进,包括允许局部和匿名类型作为模板实参、将函数模板添加到ADL查找规则中等。

第38章讨论了新标准模板推导的优化,在C++17标准之前实例化类模板必须显式指定模板实参,但是现在不需要了。本章介绍了使用构造函数推导类模板实参的方法以及它在各种场景下的应用。

第39章介绍了用户定义推导指引,读者通过本章将学到如何通过自定义推导指引来控制编译器推导模板实例路径。

第40章讨论了SFINAE规则,通过SFINAE规则开发人员能够控制编译器选择模板实例化的方法,SFINAE规则也是模板元编程必不可少的组成部分。

第41章深入探讨了概念和约束,通过这部分内容读者可以体会到对编译器前所未有的掌控力,概念可以通过各方面约束编译器对模板的实例化。本章详细讨论了concept和requires的语法和使用规则,并且展示了其在可变参数模板和auto中的约束作用。

第42章介绍了模板特性的其他优化,包括新增的变量模板以及使用friend声明模板形参的优化等。

本书并不是直接告诉读者C++11~C++20的新特性该怎么使用,而是希望读者通过本书能够了解新特性诞生的前因后果,用实际例子探讨过去C++中的缺陷以及新特性如何修复和完善优化,并且尽可能地描述新特性在编译器中的实现原理。它没有告诉读者“你应该这样使用这个新特性”,而是在说“嘿,我有一个不错的新特性,可以解决你手中的问题,它的原理是……而且关于这个特性我还有一个小故事,想听听么?”另外,为了保证新特性被编译器切实有效地实现,本书中几乎所有的代码都采用GCC、Clang和MSVC编译过。在编译器表现与C++标准描述不一致的时候会提醒读者注意其中的区别。

为什么我的类在使用C++17标准后无法初始化对象了?

为什么在不改变任何代码的情况下,用新编译器编译的程序运行效率提高了?

想定义lambda表达式用于异步调用,却发现导致未定义的行为该怎么办?

想让编辑器自动推导返回类型该怎么办?

作为库的作者,想在客户调用库代码的时候判断客户提供的类是否具有某个成员函数,以及采用不同的实现方案时该怎么做?

读完这本书读者不仅会找到以上这些问题的答案,还将了解答案背后的原理和故事。

本书的读者需要具有一定的C++基础,并且想要学习C++新特性或者因为工作项目需要学习C++新特性。对于有基础的读者来说,本书的大部分章节都比较容易理解,极少数章节可能需要反复阅读以加深理解。模板相关的大部分章节也不会成为阅读的障碍,有泛型编程和模板元编程经验的读者理解起来会更快一些。对于初学者来说,建议在阅读的时候手边备一本C++编程基础的图书,在阅读本书的时候会经常用到。

感谢我的好友赵歆、李正伟,你们当年的提议和3年多来的鼓励给了我写这本书的信心和动力。

感谢人民邮电出版社的各位编辑对本书出版付出的辛勤劳动,特别感谢陈聪聪编辑在本书从草稿到出版过程中对我的帮助,正是您的热情指导才让这本书如此迅速地与读者相见,也特别感谢张银奎老师对本书的认可并且为本书作序,谢谢你们。

最后要感谢我的家人,没有你们的默默付出、鼓励和支持,我可能无法提笔写下这本书,感谢你们。


本书由异步社区出品,社区(https://www.epubit.com/)为读者提供相关资源和后续服务。

本书提供如下资源:

要获得以上配套资源,请在异步社区本书页面中单击,跳转到下载界面,按提示进行操作即可。注意:为保证购书读者的权益,该操作会给出相关提示,要求输入提取码进行验证。

作者和编辑尽最大努力来确保书中内容的准确性,但难免会存在疏漏。欢迎读者将发现的问题反馈给我们,帮助我们提升图书的质量。

当读者发现错误时,请登录异步社区,按书名搜索,进入本书页面,单击“提交勘误”,输入错误信息,单击“提交”按钮即可。本书的作者和编辑会对读者提交的错误信息进行审核,确认并接受后,读者将获赠异步社区的100积分。积分可用于在异步社区兑换优惠券、样书或奖品。

我们的联系邮箱是chencongcong@ptpress.com.cn。

如果读者对本书有任何疑问或建议,请读者发邮件给我们,并请在邮件标题中注明本书书名,以便我们更高效地做出反馈。

如果读者有兴趣出版图书、录制教学视频,或者参与图书翻译、技术审校等工作,可以发邮件给我们;有意出版图书的作者也可以提交投稿。

如果读者所在的学校、培训机构或企业,想批量购买本书或异步社区出版的其他图书,也可以发邮件给我们。

如果读者在网上发现有针对异步社区出品图书的各种形式的盗版行为,包括对图书全部或部分内容的非授权传播,请读者将怀疑有侵权行为的链接发邮件给我们。读者的这一举动是对作者权益的保护,也是我们持续为读者提供有价值的内容的动力之源。

“异步社区”是人民邮电出版社旗下IT专业图书社区,致力于出版精品IT技术图书和相关学习产品,为作译者提供优质出版服务。异步社区创办于2015年8月,提供大量精品IT技术图书和电子书,以及高品质技术文章和视频课程。更多详情请访问异步社区官网https://www.epubit.com。

“异步图书”是由异步社区编辑团队策划出版的精品IT专业图书的品牌,依托于人民邮电出版社的计算机图书出版积累和专业编辑团队,相关图书在封面上印有异步图书的LOGO。异步图书的出版领域包括软件开发、大数据、AI、测试、前端、网络技术等。

异步社区

微信服务号


整型long long虽然是C++11才新加入标准的,但是我们似乎很早就开始使用这个类型了,这其中包含了一个有趣的故事。

long long这个类型早在1995年6月之前就由罗兰·哈廷格(Roland Hartinger)提出申请加入C++标准。但是当时的C++标准委员会以C语言中不存在这个基本类型为由,拒绝将这个类型加入C++中。而就在C++98标准出台的一年后,C99标准就添加了long long这个类型,并且流行的编译器也纷纷支持了该类型,这也就是我们很早就接触到long long的原因。在此之后C++标准委员会在C++11中才有计划将整型long long加入标准中。

我们知道long通常表示一个32位整型,而long long则是用来表示一个64位的整型。不得不说,这种命名方式简单粗暴。不仅写法冗余,而且表达的含义也并不清晰。如果按照这个命名规则,那么128位整型就该被命名为long long long了。但是不管怎么样,long long既然已经加入了C++11的标准,那么我们能做的就是适应它,并且希望不会有long long long这种类型的诞生。

C++标准中定义,long long是一个至少为64位的整数类型。请注意这里的用词“至少”,也就说long long的实际长度可能大于64位。不过我至今也没有看到大于64位长度的long long出现。另外,long long是一个有符号类型,对应的无符号类型为unsigned long long,当然读者可能看到过诸如long long intunsigned long long int等类型,实际上它们和long longunsigned long long具有相同的含义。C++标准还为其定义LLULL作为这两种类型的字面量后缀,所以在初始化long long类型变量的时候可以这么写:

long long x = 65536LL;

当然,这里可以忽略LL这个字面量后缀,直接写成下面的形式也可以达到同样的效果:

long long x = 65536;

要强调的是,字面量后缀并不是没有意义的,在某些场合下我们必须用到它才能让代码的逻辑正确,比如下面的代码:

long long x1 = 65536 << 16;      // 计算得到的x1值为0
std::cout << "x1 = " << x1 << std::endl;

long long x2 = 65536LL << 16;    // 计算得到的x2值为4294967296(0x100000000)
std::cout << "x2 = " << x2 << std::endl;

以上代码的目的是将65536左移16位,以获得一个更大的数值。但是,x1计算出来的值却是0,没有增大反而减小了。原因是在没有字面量后缀的情况下,这里的65536被当作32位整型操作,在左移16位以后,这个32位整型的值变成了0,所以事实是将0赋值给了x1,于是我们看到x1输出的结果为0。而在计算x2的过程中,代码给65536添加了字面量后缀LL,这使编译器将其编译为一个64位整型,左移16位后仍然可以获得正确的结果:4294967296(0x100000000)。另外,有些编译器可能在编译long long x1 = 65536 << 16;的时候显示一些警告提示,而另一些编译器可能没有,无论如何我们必须在编写代码的时候足够小心,避免上面情况的发生。

和其他整型一样,long long也能运用于枚举类型和位域,例如:

 enum longlong_enum : long long {
      x1,
      x2
 };

 struct longlong_struct {
      long long x1 : 8;
      long long x2 : 24;
      long long x3 : 32;
 };

std::cout << sizeof(longlong_enum::x1) << std::endl;  // 输出大小为8
std::cout << sizeof(longlong_struct) << std::endl;    // 输出大小为8

作为一个新的整型long long,C++标准必须为它配套地加入整型的大小限制。在头文件中增加了以下宏,分别代表long long的最大值和最小值以及unsigned long long的最大值:

#define LLONG_MAX 9223372036854775807LL         // long long的最大值
#define LLONG_MIN (-9223372036854775807LL - 1)  // long long的最小值
#define ULLONG_MAX 0xffffffffffffffffULL        // unsigned long long的最大值

在C++中应该尽量少使用宏,用模板取而代之是明智的选择。C++标准中对标准库头文件做了扩展,特化了long longunsigned long long版本的numeric_ limits类模板。这使我们能够更便捷地获取这些类型的最大值和最小值,如下面的代码示例:

#include <iostream>
#include <limits>
#include <cstdio>
int main(int argc, char *argv[])
{
      // 使用宏方法
      std::cout << "LLONG_MAX = " << LLONG_MAX << std::endl;
      std::cout << "LLONG_MIN = " << LLONG_MIN << std::endl;
      std::cout << "ULLONG_MAX = " << ULLONG_MAX << std::endl;

      // 使用类模板方法
      std::cout << "std::numeric_limits<long long>::max() = " 
            << std::numeric_limits<long long>::max() << std::endl;
      std::cout << "std::numeric_limits<long long>::min() = " 
            << std::numeric_limits<long long>::min() << std::endl;
      std::cout << "std::numeric_limits<unsigned long long>::max() = " 
            << std::numeric_limits<unsigned long long>::max() << std::endl;

      // 使用printf打印输出
      std::printf("LLONG_MAX = %lld\n", LLONG_MAX);
      std::printf("LLONG_MIN = %lld\n", LLONG_MIN);
      std::printf("ULLONG_MAX = %llu\n", ULLONG_MAX);
}

输出结果如下:

LLONG_MAX = 9223372036854775807
LLONG_MIN = -9223372036854775808
ULLONG_MAX = 18446744073709551615
std::numeric_limits<long long>::max() = 9223372036854775807
std::numeric_limits<long long>::min() = -9223372036854775808
std::numeric_limits<unsigned long long>::max() = 18446744073709551615
LLONG_MAX = 9223372036854775807
LLONG_MIN = -9223372036854775808
ULLONG_MAX = 18446744073709551615

以上代码很容易理解,唯一需要说明的一点是,随着整型long long的加入,std::printf也加入了对其格式化打印的能力。新增的长度指示符ll可以用来指明变量是一个long long类型,所以我们分别使用%lld%llu来格式化有符号和无符号的long long整型了。当然,使用C++标准的流输入/输出是一个更好的选择。

在C++11标准中添加两种新的字符类型char16_tchar32_t,它们分别用来对应Unicode字符集的UTF-16和UTF-32两种编码方法。在正式介绍它们之前,需要先弄清楚字符集和编码方法的区别。

通常我们所说的字符集是指系统支持的所有抽象字符的集合,通常一个字符集的字符是稳定的。而编码方法是利用数字和字符集建立对应关系的一套方法,这个方法可以有很多种,比如Unicode字符集就有UTF-8、UTF-16和UTF-32这3种编码方法。除了Unicode字符集,我们常见的字符集还包括ASCII字符集、GB2312字符集、BIG5字符集等,它们都有各自的编码方法。字符集需要和编码方式对应,如果这个对应关系发生了错乱,那么我们就会看到计算机世界中令人深恶痛绝的乱码。不过,现在的计算机世界逐渐达成了一致,就是尽量以Unicode作为字符集标准,那么剩下的工作就是处理UTF-8、UTF-16和UTF-32这3种编码方法的问题了。

UTF-8、UTF-16和UTF-32简单来说是使用不同大小内存空间的编码方法。

UTF-32是最简单的编码方法,该方法用一个32位的内存空间(也就是4字节)存储一个字符编码,由于Unicode字符集的最大个数为0x10FFFF(ISO 10646),因此4字节的空间完全能够容纳任何一个字符编码。UTF-32编码方法的优点显而易见,它非常简单,计算字符串长度和查找字符都很方便;缺点也很明显,太占用内存空间。

UTF-16编码方法所需的内存空间从32位缩小到16位(占用2字节),但是由于存储空间的缩小,因此UTF-16最多只能支持0xFFFF个字符,这显然不太够用,于是UTF-16采用了一种特殊的方法来表达无法表示的字符。简单来说,从0x0000~0xD7FF以及0xE000~0xFFFF直接映射到Unicode字符集,而剩下的0xD800~0xDFFF则用于映射0x10000~0x10FFFF的Unicode字符集,映射方法为:字符编码减去0x10000后剩下的20比特位分为高位和低位,高10位的映射范围为0xD800~0xDBFF,低10位的映射范围为0xDC00~0xDFFF。例如0x10437,减去0x10000后的高低位分别为0x1和0x37,分别加上0xD800和0xDC00的结果是0xD801和0xDC37。

幸运的是,一般情况下0xFFFF足以覆盖日常字符需求,我们也不必为了UTF-16的特殊编码方法而烦恼。UTF-16编码的优势是可以用固定长度的编码表达常用的字符,所以计算字符长度和查找字符也比较方便。另外,在内存空间使用上也比UTF-32好得多。

最后说一下我们最常用的UTF-8编码方法,它是一种可变长度的编码方法。由于UTF-8编码方法只占用8比特位(1字节),因此要表达完数量高达0x10FFFF的字符集,它采用了一种前缀编码的方法。这个方法可以用1~4字节表示字符个数为0x10FFFF的Unicode(ISO 10646)字符集。为了尽量节约空间,常用的字符通常用1~2字节就能表达,其他的字符才会用到3~4字节,所以在内存空间可以使用UTF-8,但是计算字符串长度和查找字符在UTF-8中却是一个令人头痛的问题。表1-1展示了UTF-8对应的范围。

▼表1-1

代码范围 十六进制

UTF-8 二进制

注释

000000~00007F 128个代码

0zzzzzzz

ASCII字符范围,字节由零开始

000080~0007FF 1920个代码

110yyyyy 10zzzzzz

第1字节由110开始,接着的字节由10开始

000800~00D7FF 00E000~00FFFF 61440个代码

1110xxxx 10yyyyyy 10zzzzzz

第1字节由1110开始,接着的字节由10开始

010000~10FFFF 1048576个代码

11110www 10xxxxxx 10yyyyyy 10zzzzzz

将由11110开始,接着的字节从10开始

对于UTF-8编码方法而言,普通类型似乎是无法满足需求的,毕竟普通类型无法表达变长的内存空间。所以一般情况下我们直接使用基本类型char进行处理,而过去也没有一个针对UTF-16和UTF-32的字符类型。到了C++11,char16_tchar32_t的出现打破了这个尴尬的局面。除此之外,C++11标准还为3种编码提供了新前缀用于声明3种编码字符和字符串的字面量,它们分别是UTF-8的前缀u8、UTF-16的前缀u和UTF-32的前缀U

char utf8c = u8'a'; // C++17标准
//char utf8c = u8'好';
char16_t utf16c = u'好';
char32_t utf32c = U'好';
char utf8[] = u8"你好世界";
char16_t utf16[] = u"你好世界";
char32_t utf32[] = U"你好世界";

在上面的代码中,分别使用UTF-8、UTF-16和UTF-32编码的字符和字符串对变量进行了初始化,代码很简单,不过还是有两个地方值得一提。

char utf8c = u8'a'在C++11标准中实际上是无法编译成功的,因为在C++11标准中u8只能作为字符串字面量的前缀,而无法作为字符的前缀。这个问题直到C++17标准才得以解决,所以上述代码需要C++17的环境来执行编译。

char utf8c = u8'好'是无法通过编译的,因为存储“好”需要3字节,显然utf8c只能存储1字节,所以会编译失败。

在C++98的标准中提供了一个wchar_t字符类型,并且还提供了前缀L,用它表示一个宽字符。事实上Windows系统的API使用的就是wchar_t,它在Windows内核中是一个最基础的字符类型:

HANDLE CreateFileW(
 LPCWSTR lpFileName,
 );

CreateFileW(L"c:\\tmp.txt", );

上面是一段在Windows系统上创建文件的伪代码,可以看出Windows为创建文件的API提供了宽字符版本,其中LPCWSTR实际上是const wchar_t的指针类型,我们可以通过L前缀来定义一个wchar_t类型的字符串字面量,并且将其作为实参传入API。

讨论到这里读者会产生一个疑问,既然已经有了处理宽字符的字符类型,那么为什么又要加入新的字符类型呢?没错,wchar_t确实在一定程度上能够满足我们对于字符表达的需求,但是起初在定义wchar_t时并没有规定其占用内存的大小。于是就给了实现者充分的自由,以至于在Windows上wchar_t是一个16位长度的类型(2字节),而在Linux和macOS上wchar_t却是32位的(4字节)。这导致了一个严重的后果,我们写出的代码无法在不同平台上保持相同行为。而char16_tchar32_t的出现解决了这个问题,它们明确规定了其所占内存空间的大小,让代码在任何平台上都能够有一致的表现。

由于字符类型增多,因此我们还需要了解一下字符串连接的规则:如果两个字符串字面量具有相同的前缀,则生成的连接字符串字面量也具有该前缀,如表1-2所示。如果其中一个字符串字面量没有前缀,则将其视为与另一个字符串字面量具有相同前缀的字符串字面量,其他的连接行为由具体实现者定义。另外,这里的连接操作是编译时的行为,而不是一个转换。

▼表1-2

源代码

等同于

源代码

等同于

源代码

等同于

u"a" u"b"

u"ab"

U"a" U"b"

U"ab"

L"a" L"b"

L"ab"

u"a" "b"

u"ab"

U"a" "b"

U"ab"

L"a" "b"

L"ab"

"a" u"b"

u"ab"

"a" U"b"

U"ab"

"a" L"b"

L"ab"

需要注意的是,进行连接的字符依然是保持独立的,也就是说不会因为字符串连接,将两个字符合并为一个,例如连接"\xA" "B"的结果应该是"\nB"(换行符和字符B),而不是一个字符"\xAB"

随着新字符类型加入C++11标准,相应的库函数也加入进来。C11在中增加了4个字符的转换函数,包括:

size_t mbrtoc16( char16_t* pc16, const char* s, size_t n, mbstate_t* ps );
size_t c16rtomb( char* s, char16_t c16, mbstate_t* ps );
size_t mbrtoc32( char32_t* pc32, const char* s, size_t n, mbstate_t* ps );
size_t c32rtomb( char* s, char32_t c32, mbstate_t* ps );

它们的功能分别是多字节字符和UTF-16编码字符互转,以及多字节字符和UTF-32编码字符互转。在C++11中,我们可以通过包含<cuchar>来使用这4个函数。当然C++11中也添加了C++风格的转发方法std::wstring_convert以及std::codecvt。使用类模板std::wstring_convertstd::codecvt相结合,可以对多字节字符串和宽字符串进行转换。不过这里并不打算花费篇幅介绍这些转换方法,因为它们在C++17标准中已经不被推荐使用了,所以应该尽量避免使用它们。

除此之外,C++标准库的字符串也加入了对新字符类型的支持,例如:

using u16string = basic_string;
using u32string = basic_string;
using wstring = basic_string;

使用char类型来处理UTF-8字符虽然可行,但是也会带来一些困扰,比如当库函数需要同时处理多种字符时必须采用不同的函数名称以区分普通字符和UTF-8字符。C++20标准新引入的类型char8_t可以解决以上问题,它可以代替char作为UTF-8的字符类型。char8_t具有和unsigned char相同的符号属性、存储大小、对齐方式以及整数转换等级。引入char8_t类型后,在C++17环境下可以编译的UTF-8字符相关的代码会出现问题,例如:

char str[] = u8"text"; // C++17编译成功;C++20编译失败,需要char8_t
char c = u8'c';

当然反过来也不行:

char8_t c8a[] = "text"; // C++20编译失败,需要char
char8_t c8 = 'c';

另外,为了匹配新的char8_t字符类型,库函数也有相应的增加:

size_t mbrtoc8(char8_t* pc8, const char* s, size_t n, mbstate_t* ps);
size_t c8rtomb(char* s, char8_t c8, mbstate_t* ps);

using u8string = basic_string;

最后需要说明的是,上面这些例子只是C++标准库为新字符类型新增代码的冰山一角,有兴趣的读者可以翻阅标准库代码,包括<atomic><filesystem><istream><limits><locale><ostream><string>以及<string_ view>等头文件,这里就不一一介绍了。

本章从C++最基础的新特性入手,介绍了整型long long以及char8_tchar16_tchar32_t字符类型。虽说这些新的基础类型非常简单,但是磨刀不误砍柴工,掌握新基础类型(尤其是3种不同的Unicode字符类型)会让我们在使用C++处理字符、字符串以及文本方面更加游刃有余。比如,当你正在为处理文本文件中UTF-32编码的字符而头痛时,采用新标准中char32_tu32string也许会让问题迎刃而解。


开发一个大型工程必然会有很多开发人员的参与,也会引入很多第三方库,这导致程序中偶尔会碰到同名函数和类型,造成编译冲突的问题。为了缓解该问题对开发的影响,我们需要合理使用命名空间。程序员可以将函数和类型纳入命名空间中,这样在不同命名空间的函数和类型就不会产生冲突,当要使用它们的时候只需打开其指定的命名空间即可,例如:

namespace S1 {
  void foo() {}
}

namespace S2 {
  void foo() {}
}

using namespace S1;

int main()
{
  foo();
  S2::foo();
}

以上是命名空间的一个典型例子,例子中命名空间S1S2都有相同的函数foo,在调用两个函数时,由于命名空间S1using关键字打开,因此S1foo函数可以直接使用,而S2foo函数需要使用::来指定函数的命名空间。

C++11标准增强了命名空间的特性,提出了内联命名空间的概念。内联命名空间能够把空间内函数和类型导出到父命名空间中,这样即使不指定子命名空间也可以使用其空间内的函数和类型了,比如:

#include <iostream>

namespace Parent {
  namespace Child1
  {
      void foo() { std::cout << "Child1::foo()" << std::endl; }
  }

  inline namespace Child2
  {
      void foo() { std::cout << "Child2::foo()" << std::endl; }
  }
}

int main()
{
  Parent::Child1::foo();
  Parent::foo();
}

在上面的代码中,Child1不是一个内联命名空间,所以调用Child1foo函数需要明确指定所属命名空间。而调用Child2foo函数则方便了许多,直接指定父命名空间即可。现在问题来了,这个新特性的用途是什么呢?这里删除内联命名空间,将foo函数直接纳入Parent命名空间也能达到同样的效果。

实际上,该特性可以帮助库作者无缝升级库代码,让客户不用修改任何代码也能够自由选择新老库代码。举个例子:

#include <iostream>namespace Parent { void foo() { std::cout << "foo v1.0" << std::endl; }}int main(){ Parent::foo();}

假设现在Parent代码库提供了一个接口foo来完成一些工作,突然某天由于加入了新特性,需要升级接口。有些用户喜欢新的特性但并不愿意为了新接口去修改他们的代码;还有部分用户认为新接口影响了稳定性,所以希望沿用老的接口。这里最直接的办法是提供两个不同的接口函数来对应不同的版本。但是如果库中函数很多,则会出现大量需要修改的地方。另一个方案就是使用内联命名空间,将不同版本的接口归纳到不同的命名空间中,然后给它们一个容易辨识的空间名称,最后将当前最新版本的接口以内联的方式导出到父命名空间中,比如:

namespace Parent {
  namespace V1 {
      void foo() { std::cout << "foo v1.0" << std::endl; }
  }

  inline namespace V2 {
      void foo() { std::cout << "foo v2.0" << std::endl; }
  }
}

int main()
{
  Parent::foo();
}

从上面的代码可以看出,虽然foo函数从V1升级到了V2,但是客户的代码并不需要任何修改。如果用户还想使用V1版本的函数,则只需要统一添加函数版本的命名空间,比如Parent::V1::foo()。使用这种方式管理接口版本非常清晰,如果想加入V3版本的接口,则只需要创建V3的内联命名空间,并且将命名空间V2inline关键字删除。请注意,示例代码中只能有一个内联命名空间,否则编译时会造成二义性问题,编译器不知道使用哪个内联命名空间的foo函数。

有时候打开一个嵌套命名空间可能只是为了向前声明某个类或者函数,但是却需要编写冗长的嵌套代码,加入一些无谓的缩进,这很难让人接受。幸运的是,C++17标准允许使用一种更简洁的形式描述嵌套命名空间,例如:

namespace A::B::C {
  int foo() { return 5; }
}

以上代码等同于:

namespace A {
  namespace B {
      namespace C {
            int foo() { return 5; }
      }
  }
}

很显然前者是一种更简洁的定义嵌套命名空间的方法。除简洁之外,它也更加符合我们已有的语法习惯,比如嵌套类:

std::vector<int>::iterator it;

实际上这份语法规则的提案早在2003年的时候就已经提出,只不过到C++17才被正式引入标准。另外有些遗憾的是,在C++17标准中没有办法简洁地定义内联命名空间,这个问题直到C++20标准才得以解决。在C++20中,我们可以这样定义内联命名空间:

namespace A::B::inline C {
    int foo() { return 5; }
}
// 或者
namespace A::inline B::C {
    int foo() { return 5; }
}

它们分别等同于:

namespace A::B { 
    inline namespace C {
        int foo() { return 5; }
    } 
}

namespace A { 
    inline namespace B { 
        namespace C {
            int foo() { return 5; }
        } 
    } 
}

请注意,inline可以出现在除第一个namespace之外的任意namespace之前。

本章主要介绍内联命名空间,正如上文中介绍的,该特性可以帮助库作者无缝切换代码版本而无须库的使用者参与。另外,使用新的嵌套命名空间语法能够有效消除代码冗余,提高代码的可读性。


相关图书

代码审计——C/C++实践
代码审计——C/C++实践
CMake构建实战:项目开发卷
CMake构建实战:项目开发卷
C++ Templates(第2版)中文版
C++ Templates(第2版)中文版
C/C++代码调试的艺术(第2版)
C/C++代码调试的艺术(第2版)
计算机图形学编程(使用OpenGL和C++)(第2版)
计算机图形学编程(使用OpenGL和C++)(第2版)
Qt 6 C++开发指南
Qt 6 C++开发指南

相关文章

相关课程