C++语言特性和技术特点探究
1 引言
C++语言许多年来一直是编程语言中排名前三的语言。这一章节我们就来学习和研究一下C++编程语言。
2 C++语言概况
C++语言是由BjarneStroustrup创建的一种通用编程语言,是C语言的扩展,即 "C with Classes"。
2.1 功能和优势
随着时间的推移,该语言已经有了很大的发展,现代C++现在除了有面向对象、泛型和其他功能特性以外,还有低级内存操作的机制。
许多厂商都提供了C++编译器,这包括自由软件基金会、LLVM、微软、英特尔、甲骨文、IBM等,因此它可以在许多平台上使用。
C++在设计上偏向于系统编程,嵌入式开发以及资源受限的软件和大型系统。
性能、效率和使用的灵活性是其设计亮点。
C++的主要优势是软件基础设施和资源受限的应用开发,这包括桌面应用、视频游戏、服务器(如电子商务、Web搜索或SQL服务器)和性能敏感的应用(如电话交换机或空间探测器)等等。
2.2 标准化
C++由国际标准化组织(ISO)进行标准化,最新的标准版本是2017年12月被ISO批准并公布的,为ISO/IEC 14882:2017 (非正式地称为C++17)。
C++编程语言最初于1998年被标准化为ISO/IEC 14882:1998,之后又经过C++03、C++11和C++14标准的修订。
现在的C++17标准取代了这些标准,增加了新的功能并且扩大了标准库。
在1998年初步标准化之前,C++是由丹麦计算机科学家Bjarne Stroustrup从1979年开始在Bell实验室开发的。
作为C语言的扩展,希望有一种类似于C语言的高效、灵活的语言,同时也为程序员群体提供高级别的功能。
C++20是下一个计划中的标准,遵循了目前每三年一个新版本的发展趋势。
2.3 理念
C++的发展和演变一直遵循着一套原则:
· 必须由实际问题驱动,其功能应该在现实世界的程序中即刻有用。
· 每个功能都应该是可实现的,即存在合理的实现方式。
· 程序员可以自由选择自己的编程风格,而且这种风格应该有C++的充分支持。
· 允许有用的功能比防止可能的误用更重要。
· 提供将程序编排成单独的、定义清晰的组件,并提供将单独开发的组件组合起来的机制。
· 不允许隐式类型转换滥用,但允许显式类型转换。
· 用户创建的类型有与内置类型相同的支持和性能。
· 未使用的特性不应该对创建的可执行文件产生负面影响,比如,性能降低。
· 在C++之下不应该有任何语言(汇编语言除外)。
· C++应该可以与其他现有的编程语言一起工作,而不是培养自己独立的、不兼容的编程环境。
· 允许程序员通过手动控制来指定自己的编程意图。
3 语言特性
C++语言有两个主要部分:
一是主要由C语言子集提供的硬件特征的直接映射,
二是基于这些映射的抽象。
Stroustrup将C++描述为 "一种轻量级的抽象编程语言,用于构建和使用高效、优雅的抽象",而 "同时提供硬件访问和抽象是C++的基础特征。能高效地做到这些是C++与其他语言的区别。"
C++继承了C语言的大部分语法。
下面是Bjarne Stroustrup的Helloworld程序的版本,该程序使用C++标准库流机制将消息写成标准输出:
#include <iostream>
int main()
{
std::cout << "Hello, world!\n";
}
3.1 存储
和C语言一样,C++支持四种类型的内存管理:静态存储对象、线程存储对象、自动存储对象和动态存储对象。
3.1.1 静态存储对象
静态存储对象是在进入main()之前创建的,并在main()退出后按照与创建顺序相反的顺序销毁。
C++标准没有规定创建的确切顺序,这是为了给实现者在组织实现时有一定的自由度。
更正式地说法是这种类型的对象有一个生命周期。
静态存储对象的初始化分两个阶段进行。
在 "静态初始化"中,所有的对象首先被初始化为零;
之后,所有具有常量初始化阶段的对象都被初始化为常量表达式。
虽然标准中没有规定,但静态初始化阶段可以在编译时完成,并保存在可执行文件的数据分区中。
初始化的顺序是这样的:首先是 "静态初始化",在所有静态初始化完成后,才进行"动态初始化"。
动态初始化涉及的初始化都是通过构造函数或函数调用完成的(除非函数中标有constexpr)。
动态初始化顺序被定义为编译单元内的声明顺序(编译单元即同一文件)。对于编译单元之间的初始化顺序不做任何保证。
3.1.2 线程存储对象
这种类型的变量与静态存储对象非常相似。主要的区别在于创建时间正好在线程创建之前,而销毁是在线程结束之后进行的。
3.1.3 自动存储对象
C++中最常见的变量类型是函数或程序块内的局部变量和临时变量。
自动存储变量的共同特点是,它们的生命周期仅限于变量的作用域。
它们在声明时被创建并进行可能的初始化,当离开作用域时,要按与创建顺序相反的顺序销毁。这是在堆栈上完成的。
本地变量在声明时创建。变量可能有一个构造函数或初始化容器用于定义对象的初始状态。
局部变量在其声明的局部程序块或函数被关闭时销毁。
C++的局部变量的解析器在对象的生命周期结束时被调用,这种自动资源管理的规范被称为RAII,在C++中被广泛使用。
如果父对象是一个 "自动存储对象",那么当它超出作用域时,它将被销毁,这将触发所有成员变量的销毁。
数组成员从0到最后一个成员依次初始化。
临时变量是作为表达式评估的结果而创建的,当包含表达式的语句被完全评估后,临时变量就会被销毁。
3.1.4 动态存储对象
这些对象有一个动态的生命周期,可以通过调用new直接创建,也可以通过调用delete直接销毁。
C++也支持malloc和free,这是从C语言中继承过来的,但这些操作与new和delete不能混用。
用new会返回所分配的内存的地址。
C++核心指南建议不要直接使用new来创建动态对象,而要通过单所有权的make_unique<T>和使用引用计数的多所有权的make_shared<T>来使用智能指针来创建动态对象,这两个方法是在C++11中引入的。
3.2 模板
C++模板实现了通用编程又称泛型编程。
3.2.1 泛型编程
C++支持函数模板、类模板、别名模板和变量模板。
模板可以通过类型、编译时常量和其他模板进行参数化。
模板在编译时通过实例化来实现。
为了实例化一个模板,编译器会将特定的参数替换成模板的参数来生成一个具体的函数或类实例。
有些替换是不可能完成的,此时由 "替换失败不是错误"(SFINAE)所描述的过载解决策略来处理。
模板是一种强大的工具,可以用于通用编程、模板元编程和代码优化,但这种能力也意味着代价的付出。
3.2.2 模版的代价
模板的使用一般会增加编译结果的尺寸,因为每一个模板实例化都会产生一个模板代码的副本:
每一组模板参数都会产生一个模板代码。
这与其他语言(如Java)中的运行时的通用性不同,在编译时,类型会被擦除,只保留一个单一的模板体。
3.2.3 模板与宏不同
虽然这两种语言特性都能实现条件编译,但模板并仅不限于词法替换。
模板能够察觉到语义和类型系统,以及编译时的类型定义,并且能够执行高级操作,比如进行基于对类型检查参数评估的程序流控制。
宏能够根据预先设定的标准对编译进行条件控制,但不能实例化新类型, 以及递归或执行类型评估,实际上它只限于编译前的文本替换和文本包含或者排除。
换句话说,宏可以根据预先定义的符号来控制编译流程,它不能独立地实例化新的符号。
模板是一种静态的、多态性的工具,是通用编程的工具。
此外,模板是C++中的一种编译进行时机制,它是图灵完全的,计算机程序所表达的任何计算都可以在运行前由模板元程序以某种形式计算出来。
简而言之,模板是在不知道具体参数的情况下编写的在编译时再参数化的函数或类的实例化。
实例化后,所产生的代码相当于专门为传递的参数编写了代码。
通过这种方式,模板提供了一种方法,可以将函数和类的通用的、广泛适用的方面(模板抽象)与特定的方面(具体的模板参数)解耦,同时又不会因为抽象而牺牲性能。
3.3 对象
C++在C语言基础上引入了面向对象编程(OOP)的特性,它提供了类的概念,提供了OOP(和一些非OOP)语言中常见的四个特性:抽象、封装、继承和多态。
与其他编程语言中的类相比,C++类的一个显著特点是支持确定性的析构函数,这个特征提供了对资源获取即初始化(RAII)概念的支持。
3.3.1 封装
封装是对信息尽可能多的进行隐藏。
C++提供了定义类和函数的能力,这也是封装机制的主要实现途径。
在类定义中,成员可以被声明为public、protected或private来执行封装理念。
类中的公有成员可以被任何函数访问。
私有成员只对作为该类成员的函数和被该类明确授予访问权限的函数和类("朋友")访问。
受保护的成员除了类本身和朋友之外,还可以被该类的子类访问。
C++通过成员函数和友函数支持封装原则。程序员可以声明一个类型的部分或全部为公共实体,并可以使这个公共实体不属于类型的一部分。
因此,C++不仅支持面向对象编程,还支持其他分解范式,如模块化编程等。
一般认为好的做法是将所有的数据私有化进行保护,只公开那些对调用客户来说需要的函数接口。
这样可以隐藏数据实现的细节,使得设计者以后可以在不改变接口的情况下,对内部实现进行改变。
3.3.2 继承
继承是指允许一种数据类型获得其他数据类型的属性。
基类的继承可以被声明为public、protected或private。
这个访问的指定器定义了不相关的类和派生类是否可以访问所继承基类的公有和受保护的成员。
只有public继承对应于通常意义上的 "继承"。其他两种形式的使用频率要低得多。
如果省略了访问指定符,那么"类"是私有继承,而"结构"是公开继承。
基类可以被声明为虚拟类,这就是所谓的虚拟继承。虚拟继承可以确保在继承中只存在一个基类,这避免了多重继承的一些模糊问题。
多重继承是大多数其他语言中没有的特性,它允许一个类从一个以上的基类中派生。
这使得继承关系非常复杂。
例如,一个 "飞行的猫"类可以同时继承 "猫"和 "飞行的哺乳动物"。
其他一些语言,如C#或Java允许继承多个接口,同时将基类的数量限制在一个,实现了类似的功能。
像C#和Java中的接口在C++中可以定义为一个只包含纯虚函数的类,通常被称为抽象基类或"ABC"。
这种抽象基类的成员函数通常在派生类中显式定义,而不是隐式继承。
3.3.3 操作符和操作符重载
C++的运算符涵盖了基本的算术、位操作、比较、逻辑运算等等。
几乎所有的运算符都可以被定义重载,只有少数几个明显的例外,如成员访问(.和.*)以及条件运算符。
可重载操作符的功能使得用户自定义类型看起来像内置类型。
可重载操作符也是许多高级C++编程技术的重要组成部分,例如智能指针。
重载操作符并不会改变该操作符的计算优先级,也不会改变操作符使用的操作数。
值得一提的是,重载的"&&"和"||"运算符会失去其短路评估属性。
3.3.4 多态性
多态性是指对象实现上有一个共同的接口,而在不同的情况下可以有不同的行为和作用。
C++支持静态多态即在编译时解析的多态性,
和动态多态即在运行时解析的多态性。
编译时的静态多态性一般不允许运行时的行为的改变,而运行时的多态性通常会带来性能上的代价。
3.3.4.1 静态多态性
3.3.4.1.1 函数重载
函数重载允许程序声明具有相同名称但参数不同的多个函数(即特别设定的多态性)。这些函数通过其形式参数的数量或类型来区分。因此,相同的函数名称可以根据使用的上下文而指向不同的函数。函数返回的类型不用于区分函数的重载。
3.3.4.1.1.1 函数默认参数
当声明一个函数时,程序员可以为一个或多个参数指定默认值。这样做允许在调用函数时省略带默认值的参数。
当一个函数被调用时,参数数量少于声明的参数时,显式参数将按从左至右的顺序与参数匹配,参数列表最后的任何未匹配的参数将被分配给默认参数。
在许多情况下,在单个函数声明中指定默认参数比提供不同数量参数的重载函数定义要好。
3.3.4.1.1.2 模板
C++中的模板为编写通用的多态代码(即参数多态)提供了一种机制。
模板模式是静态多态性的另一种实现形式。
由于C++模板具有类型感知性和图灵完备性,也可以通过模板元编程让编译器解决递归问题,进而生成实质性的程序。
3.3.4.2 动态多态性
3.3.4.2.1 继承
C++中的基类类型的变量指针可以引用该类型的任何派生类对象,这使得动态(运行时)多态性得以实现,其中被引用的对象可以根据它们实际派生的类型而表现出不同的行为。
C++还提供了dynamic_cast操作符,允许通过基本引用/指针将一个对象安全地尝试转换为更多的派生类型:downcasting。
dynamic_cast依赖于运行时的类型信息(RTTI),即程序中的元数据,从而可以区分类型和它们的关系。
如果对指针的dynamic_cast失败,结果会返回空指针常量,而如果目标是引用类型(不能为空),则会抛出一个异常。
已知派生类型的对象可以用static_cast来转换到该类型,这就绕过了RTTI和dynamic_cast的安全运行时类型检查。
只有当程序员非常确信这种类型转换的时候才可以使用。
3.3.4.2.2 虚函数
通常情况下,当派生类中的函数覆盖基类中的函数时,所调用的函数由对象的类型决定。
当一个给定的函数没有参数数量或类型差异时,就会被重写, 在编译的时候,一般无法确定对象的类型。
因此,这个决定被推迟到运行时再做。这就是所谓的动态调度。
虚拟成员函数或方法允许根据对象的实际运行时的类型调用最具体的函数实现。在C++实现中,这通常是利用虚拟函数表来实现的。
如果对象类型是已知的,可以通过在函数调用前预置一个完全合格的类名来绕过。但一般情况下,对虚拟函数的调用是在运行时解决的。
除了标准的成员函数外,操作符重载和析构函数也可以是虚拟的。
作为经验法则,如果类中的函数是虚拟的,那么析构函数也应该是虚拟的。
由于对象在创建时的类型在编译时就已经知道了,所以构造函数以及复制构造函数不能是虚拟的。
有时候在使用基类对象指针时,需要创建一个对象的副本,一个常见的解决方案是创建一个clone() 或类似的虚拟函数,在调用时创建并返回派生类的副本。
一个成员函数也可以通过在结尾括号后和分号前加上= 0来使其成为"纯虚拟函数"。
一个包含纯虚拟函数的类被称为抽象类。对象不能从抽象类中创建,只能从派生类中派生。
如果派生类继承了纯虚函数,派生类的对象在被创建之前,必须提供一个纯虚函数的具体实现。
3.4 Lambda表达式
C++提供了对匿名函数的支持,也称为lambda表达式,其形式如下:
[capture](parameters) -> return_type { function_body }
还可以自动推断出lambda表达式的返回类型,例如:
[](int x, int y) { return x + y; } // 推论
[](int x, int y) -> int { return x + y; } // 显式的
[捕获]列表支持闭包的定义。这种lambda表达式在标准中被定义为未命名的函数对象。
3.5 异常处理
异常处理是用来传送运行时错误的机制,这个传送是从检测到错误的地方到可以处理错误的地方。
它允许在检测到任何错误时,以统一的方式把错误处理与主逻辑代码分开。
如果程序发生了错误,异常机制就会抛出一个异常,然后由最近的可匹配的异常处理程序捕获。
异常处理会导致当前作用域的退出,同时也可能会导致外部作用域的退出,直到找到匹配的异常处理程序为止。
这个过程会依次调用这些退出的作用域中的所有对象的析构函数。
于此同时,异常处理程序会在匹配的异常处理程序中读取处理错误信息数据对象。
一些C++的编程指南,如Google的 LLVM和Qt就禁止使用异常。
主逻辑代码被放置在try块中,异常处理部分在catch块中。
每个try块可以有多个异常处理程序,如下所示:
#include <iostream>
#include <vector>
#include <stdexcept>
int main() {
try {
std::vector<int> vec{3, 4, 3, 1};
int i{vec.at(4)}; // 抛出一个异常,std:::out_of_range (vec的索引是0-3,而不是1-4) }
// 一个异常处理程序,捕捉到 std:::out_of_range,它是由 vec.at(4) 抛出的。
catch (std::out_of_range &e) {
std::cerr << "Accessing a non-existent element: " << e.what() << '\n';
}
// 要捕获任何其他标准库的异常(可以匹配std:::exception的异常)。
catch (std::exception &e) {
std::cerr << "Exception thrown: " << e.what() << '\n';
}
// 捕获任何未被识别的异常(即那些不匹配std:::exception的异常)。
catch (...) {
std::cerr << "Some fatal error\n";
}
}
有时候也可以使用 throw 关键字主动的抛出异常.
上面这些异常的处理方式都是通常的方式。
在某些情况下,由于技术原因不能使用异常机制。比如嵌入式系统的某个关键组件必须保证每个操作在指定的时间内完成。这样的情况下不能使用异常机制。
异常处理与信号处理不同。
在信号处理中,处理函数从故障点开始调用,而异常处理则是在进入捕获块之前退出了当前的程序范围,而捕获块可能位于当前函数或当前堆栈之前的任何一个的函数调用中。
4 标准库
C++标准由两部分组成:核心语言库和标准库。
C++程序员希望在C++的每一个主要实现中都能用到后者。
它包括很多集合类型(向量、列表、映射、集、队列、堆栈、数组、图集)、算法(find、for_each、二进制搜索、随机洗牌等)、输入/输出设施(iostream,用于对控制台和文件的读写)、文件系统库、本地化支持、智能指针、正则表达式、多线程、原子操作支持、时间工具、一个不使用C++异常的错误报告系统、一个随机数生成器和稍作修改的C标准库(使其符合C++类型系统)。
C++库的很大一部分是基于标准模板库(STL)。
STL提供的有用的工具包括作为对象集合的容器(如向量和列表),提供类似数组访问容器的迭代器,以及执行搜索和排序等操作的算法。
此外,还提供了(multi)map(关联数组)和(multi)set,它们都可以导出兼容的接口。
因此,使用模板库可以写出通用的算法,这些算法可以在任何容器或由迭代器定义的任何序列上工作。
和C语言一样,C++通过使用#include指令包含标准头文件来访问程序库的功能。
C++标准库提供了105个标准头文件,其中有27个已经被废弃了。
STL最初是由AlexanderStepanov设计的,他在通用算法和容器方面做了多年的实验。
他发现C++语言中可以创建通用算法(例如STL sort),其性能甚至比C标准库qsort还要好,这要归功于C++的特性,比如使用内联和编译绑定而不是函数指针。
大多数C++编译器都提供了一个符合标准的C++标准库。
5 C++核心指南
C++核心指南是由C++的发明者Bjarne Stroustrup和C++ ISO工作小组召集人兼主席Herb Sutter领导的一个倡议,目的是通过使用C++14和更新的语言标准来帮助程序员编写 "现代的C++"程序。
其主要目的是为了高效、一致地编写出类型和资源安全的C++代码。
《核心指南》在2015年CPPCon大会的开幕式主题演讲中做了宣布。
伴随着核心指南一起发布的还有指南支持库(Guideline SupportLibrary,GSL)。
这个指南支持库仅包含类型定义头文件和函数库,用来实现核心指南中用到的类型和函数,以及用于执行指南规则的静态检查工具。
6 兼容性
为了给编译器厂商更大的自由度,C++标准委员会决定不对名称修改、异常处理和其他特定实现进行强行规定。
这个决定的坏处是不同的编译器产生的对象代码可能会不兼容。
6.1 与C相比
· C++通常被认为是C语言的一个超集,但严格来说并不是这样的。
· 大多数C语言的代码可以在C++中正确编译,但有一些差异会导致一些有效的C语言代码在C++中是无效的或不合理的。
例如,C语言允许从void*到其他指针类型的隐式转换,但C++是不允许的,这是出于类型安全的考虑。
· 另外,C++定义了许多新的关键字,如new和class,这些关键字可以在C程序中用作标识符,例如变量名。
· 1999年C标准的修订版(C99)已经消除了一些不兼容的地方,现在支持C++的特性,如行注释(//)和与赋值代码混合的声明。
· 另一方面,C99引入了许多C++不支持的或者不必要的新特性,如可变长度的数组、原生的复数类型、指定的初始化器、复合字库、以及restrict关键字。
· C++11标准引入了新的不兼容功能,如不允许将字符串字段赋值给字符指针,而字符指针仍然是有效的C语言用法。
· 任何函数声明或定义,如果要在C和C++中同时调用,必须通过将其放在extern "C"{/*...*/}块中的方式,以C的链接方式进行声明。这样的函数一般不使用函数重载等基于名称混杂的特性。
7 批评
尽管C++语言被广泛采用,但一些著名的程序员还是对C++语言提出了批评,这包括LinusTorvalds、Richard Stallman、JoshuaBloch、Ken Thompson和Donald Knuth。
C++最常被人诟病的一点是语言的复杂性。
其他的抱怨包括:
· 缺乏反省(Reflection);
· 垃圾收集;
· 编译时间长;
· 错误信息不准确,特别是模板元编程的错误信息。
8 小结
本文对C++语言的概况,语言特性包括存储,模板,对象,Lambda表达式,异常处理等方面进行了学习和探讨,希望可以抛砖引玉,对大家有所裨益。
- 点赞
- 收藏
- 关注作者
评论(0)