0%

C++程序设计语言 第1-3部分

第一章 致读者

1.1 本书结构

  • 纯粹的入门教材通常会这样组织其内容–所有概念都先回介绍再应用,因此必须从第一页开始顺序阅读。与之相反,纯粹的参考手册则可以从任何地方开始查阅,因为每个主题的描述都简明扼要,辅以指向相关主题的引用。
  • 本书包含以下四个部分
    • 第一部分:第一章是本书的导引,会介绍一点C++的背景知识。第2-5章对C++语言及其标准库进行简要介绍
    • 第二部分:第6-15章介绍C++的内置类型和基本特性以及如何用他们构造程序
    • 第三部分:第16-29章介绍C++的抽象机制及如何用这些机制编写面向对象和泛型编程
    • 第四部分:第30-44章概述标准库并讨论一些兼容性问题。

1.1.1 引言

  • 接下来几章将要简要介绍C++程序设计语言及其标准库的主要概念和特性
    • 第二章:基础知识。介绍C++的内存模型,计算模型和错误处理模型
    • 第三章:抽象机制。介绍用来支持数据抽象,面向对象编程以及泛型编程的语言特性
    • 第四章:容器与算法。介绍标准库提供的字符串,简单I/O,容器和算法等特性
    • 第五章:并发和实用功能。概述与资源管理,并发,数字计算,正则表达式及其他一些方面相关的标准库工具

1.1.2 基本特性

  • C++支持传统的C语言编程风格,第二部分重点介绍支持C编程风格的C++子集,包括类型,对象,作用域和存储的基本概念。
    • 第六章:类型与声明。基础类型,命令,作用域,初始化,简单类型推断,对象生命周期和类型别名
    • 第七章:指针,数组与引用
    • 第八章:结构,联合与枚举
    • 第九章:语句。声明语句,选择语句,迭代语句,goto语句和注释语句
    • 第十一章:选择适当的操作。逻辑运算符,条件表达式,递增和递减,自由空间,{}列表,lambda表达式和显式类型转换
    • 第十二章:函数。函数声明和定义,inline函数,constexpr函数,实参传递,重载函数,前置和后置条件,函数的指针和宏
    • 第十三章:异常处理。错误处理风格,异常保证,资源管理,强制不变量,throw和catch,一个vector的实现
    • 第十四章:名字空间。namespace,模块化和接口,使用名字空间组织代码。
    • 第十五章:源文件与程序。分离编译,链接,使用头文件及程序启动和结束

1.1.3 抽象机制

  • 第三部分介绍的C++特性用来支持不同形式的抽象,包括面向对象编程和泛型编程。所有章节可以粗略分为三组:类,类继承和模板
    • 第十六章:类。用户自定义类型,也就是类的概念,是所有C++抽象机制的基础
    • 第十七章:构造,清理,拷贝和移动。展示了程序员如何定义类对象创建和初始化操作的含义。此外,拷贝,移动和析构的含义同样可由程序员来定义
    • 第十八章:运算符重载。介绍了为用户自定义类型指定运算符含义的规则,重点介绍常用的算术和逻辑运算符
    • 第十九章:特殊运算符。讨论用户自定义的非算术运算符的使用
    • 第二十章:派生类。介绍构建类层次的基本语言特性及其基本使用方法。我们可以实现接口(抽象类)与其实现(派生类)的完全分离,两者间的联系通过虚函数提供
    • 第二十一章:类层次。讨论有效的使用类层次的方法。
    • 第二十二章:运行时类型信息。介绍如何使用存储在对象中的数据实现在类层次中导航。我们可以使用dynamic_cast查询一个基类对象是否是作为派生类对象定义的
    • 第二十三章:模板。介绍隐藏在模板及其使用方法之下的基本原理
    • 第二十四章:泛型程序设计。介绍设计泛型程序所需的基本技术
    • 第二十五章:特例化。介绍特例化技术,即如何利用给定的一组模板参数,从模板生成类和函数
    • 第二十六章:实例化。主要介绍名字绑定规则
    • 第二十七章:模板和类层次。介绍模板层次和类层次如何结合使用
    • 第二十八章:元编程。介绍如何用模板生成程序
    • 第二十九章:一个矩阵设计。

1.1.4 标准库

  • 实际上这一部分可以当作标准库组件的用户手册来使用
    • 第三十章:标准库概览。给出标准库的概览,列出标准库头文件,并介绍语言支持和程序诊断方面的支持
    • 第三十一章:STL容器。介绍迭代器,容器和算法框架中的容器
    • 第三十二章:STL算法。介绍STL中的算法
    • 第三十三章:STL迭代器。介绍STL中的迭代器和其他工具
    • 第三十四章:内存和资源。介绍内存和资源管理相关的工具组件
    • 第三十五章:工具。介绍一些重要性稍低的工具组件
    • 第三十六章:字符串。介绍标准库string
    • 第三十七章:正则表达式
    • 第三十八章:I/O流。介绍标准库I/O流,包括格式化和非格式化输入输出,错误处理以及缓冲
    • 第三十九章:区域设置。
    • 第四十章:数值计算。
    • 第四十一章:并发。介绍C++基本内存模型和C++所提供的支持无锁并发编程的工具
    • 第四十二章:线程和任务。介绍支持线程和锁风格并发编程的类和支持基于任务并发编程模式的类
    • 第四十三章:C标准库。介绍纳入C++标准库的C标准库特性
    • 第四十四章:兼容性。

1.2 C++的设计

  • 程序设计语言的目的就是帮助我们用代码来表达思想。因此,一种程序设计语言要完成两个相关的任务:为程序员提供一个工具,用来指明需要由计算机执行什么动作;为程序员提供一组概念,用于思考能做些什么。

  • C++的设计理念是同时提供:

    • 将内置操作和内置类型直接映射到硬件,从而提供高效的内存利用和高效的底层操作。
    • 灵活且低开小的抽象机制,使得用户自定义类型无论是符号表达,使用范围还是性能都能与内置类型相当。
  • 系统程序设计(system programming)的含义是编写直接使用硬件资源的,严重受限于资源的代码,或是编写的代码与这类代码联系紧密。特别是软件基础设施的实现(例如设备驱动程序,通信协议栈,虚拟机,操作系统,业务支持系统,编程环境以及基础库)大部分都属于系统程序设计。

1.2.1 程序设计风格

  • 我们可以简单描述软件设计和编程的基本理念:

    • 用代码直接表达想法
    • 无关的想法应独立表达
    • 用代码直接描述想法之间的关联
    • 可以自用的组合用代码表达想法,但仅在这种组合有意义时
    • 简单的想法应简单表达
  • C++语言特性直接支持四种程序设计风格

    • 过程式程序设计
    • 数据抽象
    • 面向对象程序设计
    • 泛型程序设计
  • 但是,重点不在于对单个程序设计风格的支持,而在于有效的组合它们。

  • 我理想中的语言特性应该能优雅的组合使用,来支持连续统一的程序设计风格和各种各样的程序设计技术

    • 过程式程序设计:这种风格专注于处理和设计恰当的数据结构。支持这种风格也是C语言的设计目标。C++对这种风格的支持体现为内置类型,运算符,语句,函数,struct和union等特性。
    • 数据抽象:这种风格专注于接口的设计以及一般实现细节的隐藏和特殊的表示方式。C++支持具体类和抽象类。一些语言特性可直接用来定义具有私有实现细节,构造函数和析构函数以及相关操作的类。而抽象类则为完全的数据隐藏提供了直接支持。
    • 面向对象程序设计:这种风格专注于类层次的设计,实现和使用。除了允许定义类框架之外,C++还提供了各种各样的特性来支持类框架中的导航以及简化由已有的类来定义新的类。类层次提供了运行时多态和封装机制。
    • 泛型程序设计:这种风格专注于通用算法的设计,实现和使用。在这里,通用的含义是,一个算法可以设计成能处理多种类型,只要这些类型满足算法对其实参的要求即可。C++支持泛型编程的主要特性是模板,模板提供了运行时参数多态。
  • 上述这些设计和编程风格的强大在于它们的综合,每种风格都对综合启动了重要作用,而这种综合实际上就是C++。因此,只关注一种风格是错误的,除非你只编写一些玩具程序,否则只关注一种风格会导致开发工作的浪费,产生非最优的程序

1.2.2 类型检查

  • 静态类型和编译时类型检查的概念对高效使用C++是极为重要的。静态类型的使用是可表达性,可维护性和新能的关键。

1.2.3 C兼容性

  • C++从C语言发展而来,它保留了C的特性作为子集。

1.2.4 语言,库和系统

  • C++的基本内置类型,运算符和语句都是计算机硬件能直接处理的:数字,字符和地址。C++没有内置的高级数据类型,也没有高级操作原语。

1.3 学习C++

  • 语言特性的存在是为了支持各种程序设计风格和技术。因此,语言的学习应该更关注掌握其固有的,内在的风格,而不是试图了解每个语言特性的所有细节。
  • 请记住学习C++细节知识的真正目的是:在良好设计所提供的语境中,有能力组合使用语言特性和库特性来支持好的程序设计风格。
  • 学习C++最重要的是重视基本概念(例如类型安全,资源管理和不变式)和程序设计技术(例如使用限定作用域的对象进行资源管理以及在算法中使用迭代器),还要注意不要迷失在语言技术性细节中。
  • 学习一门程序设计语言的目的是成为一个更好的程序员,即,能更高效的设计和实现新系统,维护旧系统。为此,领悟编程和设计技术比了解所有细节重要得多。

1.3.1 用C++编程

  • C++程序设计的主要理念与大多数高级语言编程一样:用代码直接表达从设计而来的概念。

1.4 C++的历史

1.4.5 C++的用途

  • C++有大量的支持库和工具集,例如
    • Boost 可移植基础库
    • POCO 网站开发库
    • QT 跨平台应用开发库
    • wxWidgets 跨平台图形用户界面库
    • WebKit 网页浏览器布局引擎库
    • CGAL 计算几何库
    • QuickFix 金融信息交换库
    • OpenCV 实时图像处理库
    • Root 高能物理库

1.5 建议

  • 每一章都有建议,给出该章节内容相关的一些具体建议。这些建议都是一些经验法则,而非不变的定律。
  • 对于初学者,下面列出了一些来自C++的设计,学习和历史这几节的建议
    • 用代码直接表达想法(概念),例如,表达为一个函数,一个类或是一个枚举
    • 编写代码应以优雅且高效为目标
    • 不要过度抽象
    • 设计应关注提供优雅且高效的抽象,可能的情况下以库的形式呈现
    • 用代码直接表达想法之间的关联,例如,通过参数化或类层次
    • 无关的想法应用独立的代码表达,例如,壁面类之间的相互依赖
    • 令资源是显式的(将它们表示为类对象)
    • 简单的想法应该简单表达
    • 使用库,特别是标准库,不要试图从头开始构建所有东西
    • 使用类型丰富的程序设计风格
    • 如果数据具有不变量,封装它
  • 总之:编写好程序需要智慧,风格和耐心。你不可能第一次就成功,要不断尝试。

第二章 C++概览:基础知识

2.1 引言

2.2 基础概念

  • C++是一种编译型语言。顾名思义,想要运行一段C++程序,需要首先用编译器把源代码转换为对象文件,然后再用链接器把这些对象组合生成可执行程序。一个C++程序通常包含许多源代码文件,通常称为源文件。
  • 一个可执行程序适用于一种特定的硬件/系统组合,是不可移植的。当我们谈论C++程序的可移植性时,通常是指源代码的可移植性。也就是说,同一份源代码可以在不同系统上成功编译并运行。
  • ISO的C++标准定义了两种实体
    • 核心语言功能,例如内置类型和循环
    • 标准库组件,例如容器和I/O操作
  • C++是一种静态类型语言,这意味着编译器在处理任何实体(例如对象,值,名称和表达式)时,都必须清楚它的类型。对象的类型决定了能在该对象上执行哪些操作。

2.2.1 Hello world

  • 在每个C++程序中有且只有一个名为main()的全局函数,在执行一个程序时首先执行该函数。如果main()返回一个int值,则这个值将作为程序给系统的返回值。如果main()没有返回任何值,则系统也将收到一个表示程序完成的值。这个值:基于Linux/Unix的环境通常会用到,而基于windows的环境一般不会用到
  • 基本上所有可执行代码都要放在函数中,并且被main()直接或间接的调用

2.2.2 类型,变量和算术运算

  • 每个名字和每个表达式都有一个类型,类型决定所能执行的操作。

  • 声明(declaration)是一条语句,负责为程序引入一个新的名字,并指定该命名实体的类型

    • 类型(type) 定义一组可能的值以及一组(对象上的)操作
    • 对象(object) 是存放某类型值的内存空间
    • 值(value) 是一组二进制位,具体的含义由类型决定
    • 变量(variable) 是一个命名的对象
  • C++提供了好几种表示初始化的符号

    • 符号 = 是一种比较传统的形式,最早被C语言使用
    • 花括号内的一组初始化器列表。最好在C++中使用更通用的{}列表形式。抛开其他因素不谈,使用初始化器列表的形式至少可以确保不会发生某些可能导致信息丢失的类型转换。
  • 我们可以使用 = 的初始化形式与auto配合,因为在此过程中不存在可能引发错误的类型转换。

  • 当我们没有明显的理由需要显式指定数据类型时,一般使用auto。在这里,明显的理由包括

    • 该定义位于一个较大的作用域中,我们希望代码的读者清楚的直到其类型
    • 我们希望明确规定某个变量的范围和精度(例如希望使用double而非float)

2.2.3 常量

  • C++支持如下两种不变性概念
    • const:大致意思是,我承诺不改变这个值。主要用于说明接口,这样在把变量传入函数时就不必担心变量会在函数内被改变了。编译器负责确认并执行const的承诺
    • constexpr:大致意思是,在编译时求值。主要用于说明常量,作用是允许将数据内置于只读内存中以及提升性能。

2.3 用户自定义类型

  • 我们把可以通过基本类型,const修饰符和声明运算符构造出来的类型称为内置类型(built-in type)
  • 我们把利用C++的抽象机制构建的新类型称为用户自定义类型(user-defined types)

第三章 C++概览:抽象机制

3.2 类

  • C++最核心的语言特性就是类。类是一种用户自定义的数据类型,用于在程序代码中表示某种概念。
  • 我们只考虑对三种重要的类的基本支持
    • 具体类
    • 抽象类
    • 类层次中的类

3.2.1 具体类型

  • 具体类的基本思想是他们的行为 就像内置类型一样。

  • 析构函数的命名规则是一个求补运算符后接类的名字,从含义上来说它是构造函数的补充。

3.5 建议

  • 直接用代码表达你的想法
  • 在代码中直接定义类来表示应用中的概念
  • 用具体类表示那些简单的概念或性能关键的组件
  • 避免裸的new和delete操作
  • 用资源句柄和RAII管理资源
  • 当接口和实现需要完全分离时使用抽象类作为接口
  • 用类层次表示具有固有的层次关系的概念
  • 在设计类层次时,注意区分实现继承和接口继承
  • 控制好对象的构造,拷贝,移动和析构操作
  • 以值的方式返回容器(依赖于移动操作以提高效率)
  • 注意强资源安全,也就是说,不要泄露任何你认为是资源的东西
  • 使用函数模板表示通用的算法

第四章 C++概览:容器与算法

4.1 标准库

  • 本书介绍的标准库设施,在任何一个完整的C++实现中都是必备的部分。

4.1.1 标准库概述

  • 标准库提供的设施可以分为以下几类
    • 运行时语言支持,例如对资源分配和运行时类型信息的支持
    • C标准库
    • 字符串和I/O流
    • 一个包含容器和算法的框架,人们习惯上称这个框架为标准模板库(STL)
    • 对数值计算的支持
    • 对正则表达式匹配的支持
    • 对并发程序设计的支持,包括thread和lock机制
    • 一系列工具,它们用于支持模板元编程,STL-风格的泛型程序设计和通用程序设计
    • 用于资源管理的智能指针和垃圾收集器接口
    • 特殊容器
  • 本质上来说,C++标准库提供了最常用的基本数据结构以及运行在之上的基础算法。

4.6 建议

  • 没必要推倒重来,直接使用标准库是最好的选择
  • 除非万不得已,大多数时候先考虑使用标准库,在考虑别的库
  • 标准库绝非万能
  • 一定要了解各种标准库容器的设计思想和优缺点
  • 优先选用vector作为你的容器类型
  • 如果你拿不准会不会越界,记得使用带边界检查的容器
  • 用push_back()或者back_inserter()给容器添加元素

第五章 C++概览:并发与实用功能

5.2 资源管理

  • 所有程序都包含一项关键人物:管理资源。所谓资源是指程序中符合先获取后释放规律的东西,比如内存,锁,套接字,线程句柄和文件句柄等。

5.2.1 unique_ptr与shared_ptr

  • 标准库提供了两种智能指针来管理自由存储上的对象
    • unique_ptr对应所有权唯一的情况
    • shared_ptr对应所有权共享的情况
  • 这些智能指针最基本的作用是防止由于编程疏忽而造成的内存泄漏。

5.3 并发

  • 并发,也就是多个任务同时执行,被广泛用于提高吞吐率(用多个处理器共同完成单个运算)和提高响应速度(允许程序的一部分在等待响应时,另一部分继续执行)
  • 标准库并发设施重点提供系统级并发机制,而不是直接提供复杂的高层并发模型。基于标准库并发设施可以构建出这类高层并发模型,并以库的形式提供。
  • 标准库直接支持在单一地址空间内并发执行多个线程。为了实现这一目的,C++提供了一个适合的内存模型和一套原子操作。

5.3.1 任务和thread

  • 我们称那些可以与其他计算并行执行的计算为任务(task)。线程是任务在程序中的系统级表示。
  • 若要启动一个与其他任务并发执行的任务,我们可以构造一个std::thread并将任务作为它的实参。这里的任务是以函数或函数对象的形式出现的。
  • 一个程序的所有线程共享单一地址空间。在这一点上线程与进程不同,进程间通常不直接共享数据。由于共享单一地址空间,因此线程间可通过共享对象互相通信。通常通过锁或其他防止数据竞争的机制来控制线程间通信。

5.3.4 等待事件

  • 有时候thread需要等待某种外部事件,比如另一个thread完成了任务或是已经过去了一段时间。最简单的事件就是时间流逝。
  • 通过外部事件实现线程间通信的基本方式是使用condition_variable,它定义在中。condition_variable提供了一种机制,允许一个thread等待另一个thread。特别的是,它允许一个thread等待某个条件(condition,通常称为一个事件,event)发生,这种条件通常是其他thread完成工作产生的结果。

5.3.5 任务通信

  • 标准库提供了一些特性,允许程序员在抽象的任务层(工作并发执行)进行操作,而不是在底层的线程和锁的层次直接进行操作。
    • future和promise用来从一个独立线程上创建出的任务返回结果
    • packaged_task是帮助启动任务以及连接返回结果的机制
    • async()以非常类似调用函数的方式启动一个任务

5.7 建议

  • 用资源句柄管理资源(RAII)
  • 用unique_ptr访问多态类型的对象
  • 用shared_ptr访问共享的对象
  • 用类型安全的机制处理并发
  • 最好避免共享数据
  • 不要为了所谓效率,而不经思考,不经测试的选择使用共享数据
  • 从并发执行任务的角度进行设计,而不是直接从thread角度思考
  • 一个库是否有用,与它的规模和复杂程度无关
  • 别轻易抱怨程序的效率低下,记得用事实说话
  • 编写代码时可以显式的令其利用某些类型的属性
  • 利用正则表达式简化模式匹配任务
  • 进行数值计算时优先选择使用库而非语言本身
  • 用numeric_limits访问数值类型的属性

第六章 类型与声明

6.1 ISO C++标准

  • 在C++标准之下,很多重要的功能都是依赖于实现的(implementation-defined)。这意味着对于语言的某个概念来说,每个实现版本都必须为之设定恰当的,定义良好的语法行为,同时详细记录行为规范。

6.1.1 实现

  • C++的一个具体实现可以有两种形式:宿主式(hosted)和独立式(freestanding)。
  • 在宿主式实现中包含了C++标准和本书描述的所有标准库功能;独立式实现包含的标准库功能可能会少一些。

6.2 类型

  • C++程序中的每个名字(标识符)都对应一种数据类型。该类型决定了这个名字(即该名字代表的实体)能执行哪些运算以及如何执行这些运算。

6.2.1

  • C++包含了一套基本类型(fundamental type),这些类型对应计算机最基本的存储单元并且展现了如何利用这些单元存储数据。

    • 布尔值类型
    • 字符类型
    • 整数类型
    • 浮点数类型
    • void类型,用以表示类型信息缺失
    • 基于上述类型,我们可以用声明符构造出更多类型
      • 指针类型
      • 数组类型
      • 引用类型
    • 除此之外,用户还能自定义类型
      • 数据结构和类
      • 枚举类型,用以表示特定值的集合
  • 其中,布尔值,字符和整数统称为整型(integral type),整型和浮点型进一步统称为算术类型(arithmetic type )。

  • 我们把枚举类型和类称为用户自定义类型(user-defined),因为用户必须先定义它们,然后才能使用;这一点显然与基本类型无须声明可以直接使用的方式不同。

  • 与之相反,我们把基本类型,指针和引用统称为内置类型(built-in type)。

6.2.2 布尔值

  • 一个布尔变量(bool)的取值或是true或者是false,布尔变量常用于表示逻辑运算的结果。
  • 根据定义,当我们把布尔值换成整数时,true转为1,而false转为0.反之,整数值也能在需要的时候隐式的转换成布尔值,其中非0整数值对应true,而0对应false。

6.2.9 对齐

  • 对象首先应该有足够的空间存放对应的变量,但这还不够。在一些及其的体系结构中,存放变量的字节必须保持一种良好的对齐方式(alignment),以便硬件在访问数据资源时足够高效(在极端情况下一次性访问所有数据)
  • 例如,4字节的int应该按字(4字节)的边界排列,而8字节的double有时也应该按字(8字节)的边界排列。
  • alignof()运算符返回实参表达式的对齐情况。

6.3 声明

  • 在C++程序中要想使用某个名字(标识符),必须先对其进行声明。换句话说,我们必须指定它的类型以便编译器指导这个名字对应的是何种实体。
  • 大多数声明(declaration)同时也是定义(definition)。我们可以把定义看成是一种特殊的声明,它提供了在程序中使用该实体所需的一切信息。尤其是当实体需要内存空间来存储某些信息时,定义语句把所需的内存预留了出来。

6.3.1 声明的结构

  • 我们可以认为一条声明语句(依次)包含5个部分:
    • 可选的前置修饰符。static,virtual
    • 基本类型。 const int
    • 可选的声明符。p[7]
    • 可选的后缀函数修饰符。const noexcept
    • 可选的初始化器或函数体。{return x;}
  • 修饰符,是指声明语句中最开始的关键字,例如virtual,extern,constexpr等。修饰符的作用是指定所声明对象的某些非类型属性。
  • 声明符由一个名字和一些可选的声明运算符组成。最常用的声明运算符包括
    • 前缀 * 指针
    • 前缀 *const 常量指针
    • 前缀 & 左值引用
    • 前缀 && 右值引用
    • 前缀 auto 函数(使用后置返回类型)
    • 后缀 [] 数组
    • 后缀 () 函数
    • 后缀 -> 从函数返回

6.3.2 声明多个名字

  • C++允许在同一条声明语句中声明多个名字,其中包含逗号隔开的多个声明符即可。
  • 读者千万要注意,在声明语句中,运算符只作用于紧邻的一个名字,对于后续的其他名字是无效的

6.3.4 作用域

  • 声明语句为作用域引入了一个新名字,换句话说,某个名字只能在程序文本的某个特定区域使用。

    • 局部作用域(local scope):函数,lambda表达式中声明的名字称为局部名字。局部名字的作用域从声明处开始,到声明语句所在的快结束为之。其中块(block)是指用一对{}包围的代码片段。对于函数和lambda表达式最外层的块来说,参数名字是其中的局部名字
    • 类作用域(class scope): 如果某个类位于任意函数,类和枚举类或其他名字空间的外部,则定义在该类中的名字称为成员名字或类成员名字。类成员名字的作用域从类声明的{开始,到类声明的结束为止
    • 名字空间作用域(namespace scope):
    • 全局作用域(global scope):
    • 语句作用域(statement scope):
    • 函数作用域(function scope):
  • 在块内声明的名字能隐藏外层快及全局作用域中的同名声明。换句话说,一个已有的名字能在块内被重新定义以指向另外一个实体。退出块后,该名字恢复原来的含义。

  • 我们可以使用作用域解析运算符::访问被隐藏了的全局名字。

6.3.5 初始化

  • 顾名思义,初始化器就是对象在初始状态下被赋予的值。初始化器又四种可能的形式

    • X a1{v};
    • X a2 = {v};
    • X a3 = v;
    • X a4(v)
  • 在这些形式中,只有第一种不受任何限制,在所有场景中都能使用。

  • 建议读者使用{}。使用{}的初始化称为列表初始化(list initialization),它能防止窄化转换。这句话的意思是

    • 如果一种整型存不下另一种整型的值,则后者不会被转换成前者。例如,允许char到int的类型转换,但是不允许int到char的类型转换
    • 如果一种浮点型存不下另一种浮点型的值,则后者不会被转换成前者。例如,允许float到double的类型转换,但是不允许double到float的类型转换
    • 浮点型的值不能转换成整型值
    • 整型值不能转换成浮点型的值
  • 当我们使用auto关键字从初始化器推断变量的类型时,没必要采用列表初始化的方式。而且如果初始化器是{}列表,则推断得到的数据类型肯定不是我们想要的结果。

  • 因此,当使用auto的时候应该选择=的初始化形式。

  • 空初始化器列表{}指定使用默认值进行初始化。大多数数据类型都有默认值

    • 对于整数类型来说,默认值是数字0的某种适当形式。
    • 指针的默认值是nullptr
    • 用户自定义类型的默认值由该类型的构造函数决定。

6.3.6 推断类型:auto和decltype()

  • C++语言提供了两种从表达式中推断数据类型的机制
    • auto根据对象的初始化器推断对象的数据类型,可能是变量,const或者constexpr的类型
    • decltype(expr)推断的对象不是一个简单的初始化器,有可能是函数的返回类型或者类成员的类型。
  • 这里所谓的推断其实非常简单:auto和decltype()只是简单报告一个编译器已知的表达式的类型。
6.3.6.1 auto类型修饰符
  • 表达式的类型越难读懂,越难书写,auto就越有用
  • 在较小的作用域中,建议程序员优先选择使用auto
  • 请注意,表达式的类型永远不会是引用类型,因为表达式会隐式的执行解引用操作。

6.3.6.3 decltype()修饰符

  • 当有一个合适的初始化器的时候可以使用auto。但是很多时候我们既想推断得到类型,又不想在此过程中定义一个初始化的变量,此时,我们应该使用声明类型修饰符decltype(expr)。其中,推断所得的结果是expr的声明类型。这种用法在泛型编程中很有效。

6.4 对象和值

  • 对象(object)是指一块连续存储区域,左值(lvalue)是指对象的一条表达式。
  • 左值的字面意思是:能用在赋值运算符左侧的东西,但其实不是所有左值都能用在赋值运算符的左侧,左值也有可能指示某个常量。未被声明成const的左值称为可修改的左值。

6.4.1 左值和右值

  • 为了补充和挖山左值的含义,我们相应的定义了右值(rvalue)。简单来说,右值是指不能作为左值的值,比如像函数返回值一样的临时值
  • 在实际编程过程中,考虑左值和右值就足够了。一条表达式要么是左值,要么是右值,不可能两者都是。

6.4.2 对象的声明周期

  • 对象的声明周期(lifetime)从对象的构造函数完成的那一刻开始,直到析构函数执行为止。对于那些没有声明构造函数的类型(比如int),我们可以认为它们拥有默认的构造函数和析构函数,并且这两个函数不执行任何实际操作。

  • 我们从生命周期的角度把对象划分成以下类别

    • 自动对象(automatic):除非程序员特别说明,否则在函数中声明的对象在其定义处被创建,当超出作用域范围时被销毁。这样的对象被称为自动对象。在大多数实现中,自动对象被分配在栈空间上。每调用一次函数,获取新的栈帧(stack frame)以存放它的自动对象。
    • 静态对象(static):在全局作用域或名字空间作用域中声明的对象以及在函数或类中声明的static成员只被创建并初始化一次,并且直到程序结束之前都活着。这样的对象被称为静态对象。静态对象在程序的整个执行周期内地址唯一。在多线程环境中,静态对象可能会造成某些意料之外的问题。因为所有线程都共享对象,所以必须为其加锁以避免数据竞争。
    • 自由存储对象(free store):用new和delete直接控制其声明周期的对象
    • 临时对象(temporary):比如计算的中间结果或者用于存放const实参引用的值的对象。临时对象的生命周期由其用法决定。
    • 线程局部对象(thread-local):或者说声明为thread_local的对象,这样的对象随着线程的创建而创建,随着线程的销毁而销毁。
  • 其中,静态和自动被称为存储类(store class)

  • 数组元素和非静态类成员的生命周期由它们所属的对象决定。

6.6 建议

  • 尽量避免不确定的和未定义的行为
  • 如果某些代码必须依赖于具体实现,记得把它们与程序的其他部分分离开来
  • 对于字符对应的数字值不要乱作假定
  • 以0开头的整数是八进制
  • 不要使用魔法常量
  • 注意带符号类型和无符号类型之间的转换
  • 在一条声明语句中只声明一个名字
  • 常用的,局部的名字尽量短;不常用的,非局部的名字可以长一些
  • 对象的名字应该尽量反应对象的含义而非类型
  • 坚持一种统一的命名风格
  • 避免使用全大写的名字
  • 作用域宜小不宜大
  • 最好不要在一个作用域以及它的外层作用域中使用相同的名字
  • 使用指定类型声明时最好用{}初始化器语法
  • 使用auto声明时最好用=语法
  • 避免使用未初始化的变量
  • 当内置类型被用来表示一个可变值时,不妨给它起个能反应其含义的别名。
  • 用别名作为类型的同义词,用枚举和类定义新类型。

第七章 指针,数组与引用

7.1 引言

  • 我们能通过名字使用对象。在C++中,对象位于内存的某个地址中,如果我们知道对象的地址和类型,就能访问它。
  • 在C++语言中存放及使用内存地址是通过指针和引用完成的。

7.2 指针

  • 对于类型T来说,T*是表示 指向T的指针 的类型。换句话说,T*类型的变量能存放T类型对象的地址。

  • 对指针的一个基本操作是解引用(dereferencing),即引用指针所指的对象。这个操作也称为间接取值(indirection)。解引用运算符是个前置一元运算符,对应的符号是*。

  • 指针的具体实现应该与运行程序的机器的寻址机制同步。大多数机器支持逐字节访问内存,其他机器则需要从字中抽取字节。很少有机器能直接寻址到一个二进制位。因此,能独立分配且用内置指针指向的最小对象是char类型的对象。

  • 有一点读者注意:bool占用的内存空间至少和char一样多。如果想把更小的值存的更紧密,可以使用位逻辑操作,结构中的位域或者bitset

  • 符号*在用作类型名的后缀时表示 指向 的含义。如果我们想表示指向数组的指针或者指向函数的指针,需要使用稍微复杂的一些方式。

    1
    2
    3
    4
    5
    int* pi;  // 指向int的指针
    char** ppc; // 指向字符指针的指针
    int* ap[15]; // ap是一个数组,包含15个指向int的指针
    int (*fp)(char*); // 指向函数的指针,该函数接受一个char*实参,返回一个int
    int* f(char*); // 该函数接受一个char*实参,返回一个指向int的指针。

7.2.1 void *

  • 在某些偏向底层的代码中,我们偶尔需要在不知道对象确切类型的情况下,仅通过对象在内存中的地址存储或传递对象。此时,我们会用到void*,其含义是 指向未知类型对象的指针。
  • 除了函数指针和指向类成员的指针,指向其他任意类型对象的指针都能被赋给一个void*类型的变量。
  • void*最主要的用途是当我们无法假定对象的类型时,向函数传递指向该对象的指针;它还用于从函数返回未知类型的对象。要想使用这样的对象,必须先进行显式类型转换。

7.2.2 nullptr

  • 字面值常量nullptr表示空指针,即不指向任何对象的指针。我们可以把nullptr赋给其他任意指针类型,但是不能赋给其他内置类型。
  • 使用nullptr的好处很多,首先它的可读性更强,其次当一组重载函数既可以接受指针也可以接受整数时,用nullptr能够避免语义混淆。

7.3 数组

  • 假设有类型T,T[size]的含义是包含size个T类型元素的数组。元素的索引值范围是0到size-1。
  • 以0作为终止符的char数组是应用最广泛的一种数组。这是C语言存储字符串的基本方式,因此我们常把0作为终止符的char数组称为C风格字符串。

7.3.1 数组的初始化器

  • 我们能用值的列表初始化一个数组,例如:
    1
    2
    int v1[] = {1, 2, 3, 4};
    char v2[] = {'a', 'b', 'c', 'd'};
  • 如果声明数组的时候没有指定它的大小但是给出了初始化器列表,则编译器会根据列表包含的元素数量自动计算数组的大小。
  • 如果我们指定了数组的大小,但是提供的初始化器列表元素数量过多,则程序会发生错误。
  • 如果初始化器提供的元素数量不足,则系统自动把剩余的元素赋值为0
  • C++没有为数组提供内置的拷贝操作。不允许用一个数组初始化另一个数组,即使两个数组的类型完全一样也不行,因为数组不支持赋值操作。同样,不允许以传值方式传递数组。
  • 如果你想给一组对象赋值,可以使用vector,array或者valarray
  • 我们可以用字符串字面量常量初始化字符的数组。

7.4 数组中的指针

  • 在C++语言中,指针与数组密切相关。数组名可以看成是指向数组首元素的指针。

7.4.3 传递数组

  • 不能以值传递的方式直接把数组传给函数,我们通常传递的是数组首元素的指针。

7.5 指针与const

  • C++提供了两种与常量有关的概念
    • constexpr:编译时求值
    • const:在当前作用域内,值不发生改变
  • 基本上,constexpr的作用是指示或确保在编译时求值,而const的主要任务是规定接口的不可修改性。
  • 很多对象的值一旦初始化就不会再改动
    • 使用符号化常量的代码比直接使用字面值常量的代码更易维护
    • 我们经常通过指针读取数据,但是很少通过指针写入数组
    • 绝大多数函数的参数只负责读取数据,很少写入数据。
  • 为了表达已经初始化就不可修改的特性,我们可以再对象的定义中加上const关键字。
  • 一旦我们把某物声明成const,就确保它的值在其作用域内不会发生改变
  • 使用const会改变一种类型。所谓改变不是说改变了常量的分配方式,而是限制了它的使用方式。

7.6 指针与所有权

  • 资源必须先分配后释放。指针是最常用的资源句柄。

7.7 引用

  • 引用作为对象的别名存放的也是对象的机器地址。与指针相比,引用不会带来额外的开销。引用和指针的区别主要包括
    • 访问引用与访问对象本身从语法形式上看是一样的
    • 引用所引的永远是一开始初始化的那个对象
    • 不存在空引用。我们可以认为应用一定对应着某个对象。
  • 引用实际上是对象的别名。引用最重要的用途是作为函数的实参或返回值,此外,它也被用于重载运算符。
  • 为了体现左值/右值以及const/非const的区别,存在三种形式的引用
    • 左值引用(lvalue reference): 引用那些我们希望改变值的对象
    • const引用(const reference): 引用那些我们不希望改变值的对象,比如常量
    • 右值引用(rvalue reference): 所引对象的值在我们使用之后就无需保留了,例如临时变量。
  • 这三种形式统称为引用,其中前两种形式都是左值引用。

7.7.1 左值引用

  • 在类型名字中,符号X&的意思是 X的引用:它常用于表示左值的引用,因此称为左值引用。

7.8 建议

  • 使用指针时越简单直接越好
  • 不要对指针执行稀奇古怪的算术运算
  • 注意不要越界访问数组,尤其不要再数组之外的区域写入内容
  • 不要使用多维数组,用合适的容器代替它
  • 用nullptr代替0和NULL
  • 与内置的C风格数组相比,优先使用容器
  • 优先选用string,而不是以0结尾的char数组
  • 如果字符串字面值常量中包含太多反斜线,则使用原始字符串
  • const引用比普通引用更适合作为函数的实参
  • 只要当需要转发和移动时才使用右值引用
  • 让表示所有权的指针位于句柄类的内部
  • 再底层代码之外尽量不要使用void*
  • 用const指针和const引用表示接口中不允许修改的部分

第八章 结构,联合与枚举

8.1 引言

  • 用户自定义类型是能否有效使用C++的关键。
  • 三种用户自定义类型的初级形式
    • struct,结构是由任意类型元素(即成员)构成的序列
    • union,是一种struct,同一时刻只保存一个元素的值
    • enum,枚举是包含一组命名常量(称为枚举值)的类型
    • enum class(限定作用域的枚举类型)是一种enum,枚举值位于枚举类型的作用域内,不存在向其他类型的隐式类型转换。

8.2 结构

  • 数组是相同类型元素的集合。相反,struct是任意类型元素的集合。
  • 结构类型的对象可以被赋值,作为实参传入函数,或者作为函数的结果返回。
  • 默认情况下,比较运算符(==, !=)等一些似是而非的操作并不适用于结构类型。当然,用户有权自定义这些运算符。

8.2.1 struct的布局

  • 在struct的对象中,成员按照声明的顺序依次存放。
  • 在内存中为成员分配空间时,顺序与声明结构的时候保持一致。
  • 然而,一个struct对象的大小不一定恰好等于它所有元素大小的累积之和。因为很多机器要求一些特定类型的对象沿着系统结构设定的边界分配空间,以便机器能高效的处理这些对象。例如,整数通常沿着字的边界分配空间。
  • 在这类机器上,我们说对象对齐(aligned)得很好。
  • 通常情况下,我们应该从可读性的角度出发设计结构成员的顺序。只有当需要优化程序的性能时,才按照成员的大小排序。

8.2.2 struct的名字

  • 类型名字只要一出现就能马上使用了,无需等到该类型的声明全部完成。例如
    1
    2
    3
    4
    5
    struct Link 
    {
    Link* previous;
    Link* successor;
    };
  • 但是,只有等到struct的声明全部完成,才能声明它的对象。例如
    1
    2
    3
    4
    struct No_good 
    {
    No_good member; // 错误:递归定义
    };
  • 因为编译器无法确定No_good的大小,所以程序会报错。要想让两个或更多struct互相引用,必须提前声明好struct的名字。例如
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    struct List;
    struct Link
    {
    Link* pre;
    Link* suc;
    List* member_of;
    int data;
    };

    struct Link
    {
    Link* head;
    };
  • 如果没有一开始声明List,则在稍后声明Link时使用List*类型的指针将造成错误。
  • 我们可以在真正定义一个struct类型之前就使用它的名字,只要在此过程中不使用成员的名字和结构的大小就行了。然而,直到struct的声明完成之前,它都是一个不完整的类型。

8.2.3 结构与类

  • struct是一种class,它的成员默认是public的。struct可以包含成员函数,尤其是构造函数。
  • 如果只是想按照默认的顺序初始化结构的成员,则不需要专门定义一个构造函数。
  • 但是如果你需要改变实参的顺序,检验实参的有效性,修改实参或者建立不变式,则应该编写一个专门的构造函数。

8.2.4 结构与数组

  • 很自然的,我们可以构建struct的数组,也可以让struct包含数组。

8.2.5 类型等价

  • 对于两个struct来说,即使它们的成员相同,它们本身仍是不同的类型。例如
    1
    2
    struct S1{int a;};
    struct S2{int a;};
  • S1和S2是两种类型,因此:
    1
    2
    S1 x;
    S2 y = x; // 错误:类型不匹配
  • struct本身的类型与其成员的类型不能混为一谈。例如
    1
    2
    S1 x;
    int i = x; // 错误:类型不匹配
  • 在程序中,每个struct只能有唯一的定义。

8.2.6 普通旧数据

  • 有时候,我们只想把对象当成普通旧数据(内存中连续字节序列)而不愿考虑那些高级语义概念,比如运行时多态,用户自定义的拷贝语义等。这么做的主要动机是在硬件条件允许的范围内尽可能高效的移动对象。

  • 例如,要执行拷贝含有100个元素的数组的任务,调用100次拷贝构造函数显然不像直接调用std::memcpy()有效率,毕竟后者只需要使用一个块移动指令即可。

  • POD(普通旧数据)是指能被 仅当作数据 处理的对象,程序员无需顾及类布局的复杂性以及用户自定义的构造,拷贝和移动语义。

  • 我们如果想把某个对象仅当作数据处理,则要求该对象必须满足下述条件

    • 不具有复杂的布局
    • 不具有非标准拷贝语义
    • 含有一个最普通的默认构造函数。

8.2.7 域

  • 看起来用一整个字节(一个char或者一个bool)表示一个二元变量(比如on/off开关)有些浪费,但是char已经是C++中能独立分配和寻址的最小对象了。我们也可以把这些微小的变量组织在一起作为struct的域(field)。域也称为位域(bit-field)
  • 我们只要指定成员所占的位数,就能把它定义成域了。

8.3 联合

  • union是一种特殊的struct,它的所有成员都分配在同一个地址空间上。因此,一个union实际占用的空间大小与其最大的成员一样。自然的,在同一时刻union只能保存一个成员的值。
  • 语言本身并不负责追踪和管理union到底存的是哪种值,这是程序员的责任。
  • 使用union的目的无非是让数据更紧密,从而提高程序的性能。然而,大多数程序即使用了union也不会提高太多;同时,使用union的代码更容易出错。因此,我认为union是一种被过度使用的语言特性,最好不要出现在你的程序中

8.3.1 联合与类

  • 从技术上说,union是一种特殊的struct,而struct是一种特殊的class。然而,很多提供给类的功能和联合无关,因此对union施加了一些限制
    • union不能含有虚函数
    • union不能含有引用类型的成员
    • union不能含有基类
    • 在union的所有成员中,最多只能有一个成员包含类初始化器
    • union不能被用作其他类的基类。
  • 这些约束规则有效的阻止了很多错误的发生,同时简化了union的实现过程。后面一点非常重要,因为union的主要作用是优化代码的性能,所以我们肯定不希望再使用union的过程中引入隐形的代价。

8.4 枚举

  • 枚举(enumeration)类型用于存放用户指定的一组整数值。枚举类型的每种取值各自对应一个名字,我们把这些值叫做枚举值(enumerator)
  • 枚举类型分为两种
    • enum class,它的枚举值名字位于enum的局部作用域内,枚举值不会隐式的转换成其他类型
    • 普通的enum,它的枚举值名字与枚举类型本身位于同一个作用域中,枚举值隐式的转换成整数
  • 通常情况下,建议程序员使用enum class。

8.4.1 enum class

  • enum class是一种限定了作用域的强类型枚举
  • 枚举常用一些整数类型表示,每个枚举值是一个整数。我们把用于表示某个枚举的类型称为它的基础类型(underlying type)。基础类型必须是一种带符号或无符号的整数类型,默认是int。我们可以显式的指定。
  • 默认情况下,枚举值从0开始,依次递增。
  • C++允许先声明一个enum class,稍后再给出它的定义
  • 一个整数类型的值可以显式的转换成枚举类型。如果这个值属于枚举的基础类型的取值范围,则转换是有效的;否则,如果超出了合理的表示范围,则转换的结果是未定义的。
  • 对enum class执行的sizeof的结果是对其基础类型执行sizeof的结果。如果没有显式指定基础类型,则枚举类型的尺寸等于sizeof(int)

8.4.3 未命名的enum

  • 一个普通的enum可以是未命名的。例如
    1
    enum {arrow_up = 1, arrow_down, arrow_sideways};
  • 如果我们需要的只是一组整型常量,而不是用于声明变量的类型,则可以使用未命名的enum。

8.5 建议

  • 如果想紧凑的存储数据,则把结构中尺寸较大的成员布局在较小的成员之前
  • 用位域表示由硬件决定的数据布局
  • 不要天真的认为仅靠把几个值打包在一个字节中就能轻易的优化内存
  • 用union减少内存空间的使用,不要将它用于类型转换
  • 用枚举类型表示一组命名的常量

第九章 语句

9.1 引言

  • C++提供了一组即符合传统又灵活易用的语句。
  • 一个声明就是一条语句,表达式的末尾加上一个分号也是一条语句。
  • 与表达式不同,语句本身没有值。语句的主要作用是指定执行的顺序。

9.2 语句概述

  • 分号本身也是一条语句,即空语句(empty statement)
  • 花括号{} 括起来的一个可能为空的语句序列称为块(block)或者复合语句(compound statement)。块中声明的名字的作用域到块的末尾就结束了。
  • 声明(declaration),是一条语句,没有赋值语句或过程调用语句;赋值和函数调用不是语句,它们是表达式。
  • for初始化语句(for-init-statement)要么是声明,要么是一条表达式语句,它们都以分号结束
  • for初始化声明(for-init-declaration)必须是一个未初始化变量的声明
  • try语句块(try-block)的作用是处理异常。

9.3 声明作为语句

  • 一个声明就是一条语句。除非变量被声明成static,否则在控制线程传递给当前声明语句的同时执行初始化器。
  • 允许把声明当成一条语句使用的目的是尽量减少由未初始化变量造成的程序错误,并且让代码的局部性更好。在绝大多数情况下,如果没有为变量找到一个合适的值,暂时不要声明它。

9.4 选择语句

  • if语句和switch语句都需要首先检测一个值
  • 条件(condition)可能是一个表达式,也可能是一个声明。

9.4.1 if语句

  • 一个名字只能在声明它的作用域中使用。在if语句中,一个分支声明的名字不能在另一个分支中直接使用。
  • if语句的一个分支不能仅有一条声明语句,没有别的语句。例如
    1
    2
    3
    4
    5
    void f1(int i)
    {
    if (i)
    int x = i + 2; // 错误:if语句分支的声明
    }

9.4.2 switch语句

  • switch语句在一组候选项(case标签)中进行选择。case标签中出现的表达式必须是整型和枚举类型的常量表达式。在同一个switch语句中,一个值最多被case标签使用一次。
  • switch语句可以用一组if语句等价的替换。
  • 谨记switch语句的每一个分支都应该有一条结束语句,否则程序将会继续执行下一个分支的内容
  • 有一种好的解决办法:在那些我们确使希望继续执行下一个分支的地方加上注释,指明程序的意图。
  • 要想结束一个分支,最常用的是break语句,有时候也可以用return语句。
  • C++允许在switch语句的块内声明变量,但是不能不初始化。如果我们确使需要switch语句中使用变量,最好把该变量的声明和使用限定在一个块中。

9.4.3 条件中的声明

  • 要想避免不小心误用变量,最好的变法是把变量的作用域限定在一个较小的范围内。

9.5 循环语句

  • 循环语句能表示成for,while和do的形式

9.5.1 范围for语句

  • 最简单的循环是范围for语句,它使得程序员可以依次访问指定范围内的每个元素
  • for(int x : v)读作 对于范围v中的每个元素x。程序从头到尾依次访问v的全部元素。
  • 命名元素的变量的作用域是整个for语句。冒号之后的表达式必须是一个序列,换句话说,如果我们对它的调用v.begin()和v.end()或者begin(v)和end(v),得到的应该是迭代器。

9.5.2 for语句

  • 如果循环不符合 引入一个循环变量,检验条件,更新循环变量 的模式,则它更适合用while语句表示。

9.5.3 while语句

  • while语句重复执行它的受控语句直到条件部分变成false
  • 与for语句相比,while语句更适合处理以下两种情况
    • 一是没有一个明显的循环变量
    • 二是程序员觉得把负责更新循环变量的语句置于循环体内更自然。

9.5.4 do语句

  • do语句与while非常相似,唯一的区别就是do语句的条件位于循环体之后。

9.5.5 退出循环

  • break语句负责跳出最近的外层switch语句
  • 当我们需要中途离开循环体的时候,可以使用break语句。通常情况下,应该让完整退出的条件位于while语句和for语句的条件部分,只要这么做不会违背循环本身的逻辑就行。

9.6 goto语句

1
2
goto 标识符;
标识符: 语句
  • 标签的作用域是标签所处的函数。这意味着你能用goto从块的范围跳进跳出,唯一的限制是不能跳过初始化器或者跳入到异常处理程序中。
  • 在一般的代码中,goto可以用来跳出嵌套的循环或者switch(语句),这是他为数不多的有意义的用法之一。

9.7 注释与缩进

  • 如果语言本身能说清楚某件事,那就不要放在注释中,而应该让语句来完成。
  • 好注释负责指明一段代码应该实现什么功能(代码的意图),而代码本身负责完成该功能(完成的方式)。最好的方式是,注释的语言应该保持在一个较高层次的抽象水平上,这样便于人们理解而无需纠结过多技术细节。
  • 关于注释,我的习惯是
    • 在针对每个源文件的注释中指明:该文件中的声明有何共同点,对应的参考手册条目,程序员以及维护该文件所需的其他信息
    • 为每个类,模板和名字空间分别编写注释
    • 为每个非平凡的函数分别编写注释并指明:函数的目的,用到的算法,以及该函数对其应用环境所做的某些设定。
    • 为全局和名字空间内的每个变量及常量分别编写注释
    • 为某些不太明显或不可移植的代码编写注释
    • 其他情况,则几乎不需要注释了。

9.8 建议

  • 直到有了合适的初始值再声明变量
  • 如果可能的话,优先选用switch语句而非if语句。
  • 如果可能的话,优先选用范围for语句而非普通的for语句
  • 当没有明显的循环变量时,优先选用while语句而非for语句
  • 避免使用do语句
  • 避免使用goto语句
  • 注释应该简短直接
  • 代码能说清楚的事情就别放在注释中
  • 注释应该表明程序的意图
  • 坚持一种缩进风格,不要轻易改变

第十一章 选择适当的操作

11.1 其他运算符

  • 逻辑运算符,位逻辑运算符,条件表达式,递增递减运算符

11.1.1 逻辑运算符

  • 逻辑运算符 &&, ||, !接受算术类型以及指针类型的运算对象,将其转换为bool类型,最后返回一个bool类型的结果。

11.1.2 位逻辑运算符

  • 位逻辑运算符 &, |, ^, ~, >> , << 作用于整型对象,即 char, short, int ,long, long long 以及对应的unsigned版本,以及 bool, wchar_t, char16_t, char32_t等类型

11.1.3 条件表达式

  • 某些if语句可以改写成条件表达式(conditional-expression),例如
    1
    2
    3
    4
    if (a <= b)
    max = b;
    else
    max = a;
  • 这段代码可以更加直观的表示为
    1
    max = (a <= b) ? b : a;
  • 其中,条件部分的括号并非必须,但是加上后能使代码更易读
  • 条件表达式能用在常量表达式中,这一点非常重要
  • 此外,throw表达式也能作为条件表达式的一个分支。

11.1.4 递增与递减

  • ++x的值是x的新值(即,x递增之后的值)。例如,y = ++x 等价于 y = (x = x + 1)
  • 与之相反,x++的值是x的旧值。例如,y = x++ 等价于 y = (t = x, x = x + 1, t),其中,t是一个与x类型相同的变量

11.2 自由存储

  • 命名对象的生命周期由其作用域决定。然而,某些情况下我们希望对象与创建它的语句所在的作用域独立开来。例如,很多时候我们在函数内部创建了对象,并且希望在函数返回后仍能使用这些对象。
  • 运算符new负责创建这样的对象,运算符delete则负责销毁它们。new分配的对象 位于自由存储之上,或者说在堆上 ,在动态内存中

11.2.1 内存管理

  • 自由存储的问题主要包括

    • 对象泄露(leaked object): 使用new,但是忘了用delete释放掉分配的对象
    • 提前释放(premature deletion): 在尚有其他指针指向该对象并且后续仍会使用该对象的情况下过早的delete
    • 重复释放(double deletion): 同一对象被释放两次,两次调用对象的析构函数。
  • 重复释放的问题在于资源管理器通常无法追踪资源的所有者

  • 在一个规模较大的程序中要想确保准确释放掉分配的每一个对象(只释放一次且确保释放点正确)实在太难了。

  • 有两种方法可以避免上述问题,我建议程序员使用这两种方法代替裸new和delete

    • 除非万不得已不要把对象放在自由存储上,优先选用作用域内的变量
    • 当你在自由存储上构建对象时,把它的指针放在一个管理器对象(manager object,有时也称为句柄)中,此类对象通常含有一个析构函数,可以确保释放资源。尽可能让这个管理器对象作为作用域内的变量出现。很多习惯于使用自由存储的场合其实都可以用移动语义代替,只要从函数中返回一个表示大对象的管理器对象就可以了
  • 关于new和delete,我的经验是应该尽量确保没有裸new,即,令new位于构造函数或类似的函数中,delete位于析构函数中,由它们提供内存管理的策略。此外,new常用作资源句柄的实参。

11.2.2 数组

  • new还能用来创建对象的数组
  • 普通的delete用于删除单个对象,delete[]负责删除数组。

11.2.3 获取内存空间

  • 自由存储运算符new, delete, new[], delete[]的实现位于 头文件中

11.2.4 nothrow new

  • 有的程序不允许出现异常,此时,我们可以是使用nothrow版本的new和delete

11.3 列表

  • {}列表构建的是某种类型的对象,因此其中包含的元素数量和类型都必须符合构建该类型对象的要求。

11.4 lambda表达式

  • lambda表达式(lambda expression),有时也称为lambda函数。它是定义和使用匿名函数对象的一种简便的方式。在图形界面中,这样的操作常被称为回调(callback)

11.4.1 实现模型

  • lambda的主体部分变为了operator()()的函数体。因为lambda并不返回值,所以operator()()是void。默认情况下,operator()()是const,因此在lambda体内部无法修改捕获的变量,这也是目前为止最常见的情况。如果你希望在lambda的内部修改其状态,则应该把它声明为mutable。当然,此时对应的operator()()就不能声明为const了。
  • 我们把由lambda生成的类的对象称为闭包对象(closure object,或者简称为闭包)

11.4.3 捕获

  • lambda的主要用途是封装一部分代码以便于将其用作参数。

11.5 显式类型转换

  • C++提供了多种显式类型转换的操作:
    • 构造,使用{}符号提供对新值类型安全的构造
    • 命名的转换,提供不同等级的类型转换
      • const_cast,对某些声明为const的对象获得写入的权力
      • static_cast,反转一个定义良好的隐式类型转换
      • reinterpret_cast,改变位模式的含义
      • dynamic_cast,动态的检查类层次关系
    • C风格的转换,提供命名的类型转换或组合。
    • 函数化符号,提供C风格转换的另一种形式。

11.6 建议

  • 与后置++运算符相比,建议优先使用前置++运算符
  • 使用资源句柄避免泄露,提前删除和重复删除
  • 除非万不得已,否则不要把对象放在自由存储上;优先使用作用域内的变量
  • 避免使用裸new和裸delete
  • 使用RAII
  • 如果需要对操作添加注释,则应该选用命名的函数对象而非lambda
  • 如果操作具有一定的通用性,则应该选用命名的函数对象而非lambda
  • lambda应该尽量简短。
  • 处于可维护性和正确性的考虑,通过引用的方式捕获一定要慎之再慎
  • 让编译器推断lambda的返回类型
  • 用T{e}构造值
  • 避免显式类型转换
  • 当不得不使用显式类型转换时,尽量使用命名的转换
  • 对于数字类型之间的转换,考虑使用运行时检查的强制类型转换,例如narrow_cast<>

第十二章 函数

12.1 函数声明

  • 在C++程序中要想做点什么事,最好的办法是调用一个函数来完成它。定义函数的过程就是描述某项操作应该如何执行的过程。我们必须首先声明一个函数,然后才能调用它。
  • 函数声明负责指定函数的名字,返回值的类型以及调用该函数所需要的参数数量和类型。
  • 函数的类型即包括返回类型也包括参数的类型。对于类成员函数来说,类的名字也是函数类型的一部分。

12.1.1 为什么使用函数

  • 函数的一项重要作用,即,把一个复杂的运算分解为若干有意义的片段,然后分别为它们命名。
  • 我们希望代码是易于理解的,因为这是实现可维护性的第一步。
  • 关于函数的一条最基本的建议是应该令其规模较小,以便于我们一眼就能知悉该函数的全部内容。对于大多数程序员来说,函数的规模最好控制在大约40行内。

12.1.2 函数声明的组成要件

  • 函数声明除了指定函数的名字,一组参数以及函数的返回类型外,还可以包含多种限定符和修饰符。我们将其总结如下
    • 函数的名字,必选
    • 参数列表,可以为空,必选
    • 返回类型,可以是void,可以是前置或后置形式,必选
    • inline,表示一种愿望:通过内联函数体实现函数调用
    • constexpr,表示当给定常量表达式作为实参时,应该可以在编译时对函数求值
    • noexcept,表示该函数不允许抛出异常
    • 链接说明,例如static
    • [[noreturn]],表示函数不会用常规的调用/返回机制返回结果
  • 此外,成员函数还能被限定为
    • virtual,表示该函数可以被派生类覆盖
    • override,表示该函数必须覆盖基类中的一个虚函数
    • final,表示该函数不能被类覆盖
    • static,表示该函数不与某一特定的对象关联
    • const,表示该函数不能修改其对象的内容。
  • 函数声明成下面的形式
    1
    2
    3
    struct S {
    [[noreturn]] virtual inline auto f(const unsigned long int *const) -> void const noexcept;
    };

12.1.3 函数定义

  • 如果函数会在程序中调用,那么它必须在某处定义(只定义一次)。函数定义是特殊的函数声明,它给出了函数体的内容
  • 函数的定义以及全部声明必须对应同一类型。不过,为了与C语言兼容,我们会自动忽略参数类型的顶层const。例如,下面两条声明语句对应的是同一个函数
    1
    2
    void f(int);    // 类型是void(int)
    void f(const int); // 类型是void(int)
  • 函数的参数名字不属于函数类型的一部分,不同的声明语句中参数的名字无需保持一致。
  • 对于一条非定义的声明语句来说,为参数命名的好处是可以简化代码文档,但是我们不一定非得这么做。相反,我们通常通过不命名某个参数来表示该参数未在函数定义中使用。
    1
    2
    3
    4
    void search(table* t, const char* key, const char*)
    {
    // 未用到第三个参数
    }
  • 一般来说,未命名的参数有助于简化代码并提升代码的可扩展性。此时,尽管某些参数未被使用,但是为其预留位置可以确保函数的调用者不会受到未来函数变动的影响。
  • 除了函数以外,我们还能调用其他一些东西,它们遵循函数的大多数规则
    • 构造函数,constructor,严格来说不是函数,因为它没有返回值,可以初始化基类和成员,我们无法得到其地址
    • 析构函数,destructor,不能被重载,我们无法得到其地址
    • 函数对象,function object,不是函数,不能被重载,但是其operator()是函数
    • lambda表达式,lambda expression,是定义函数对象的一种简写形式。

12.1.4 返回值

  • 每个函数声明都包含函数的返回类型,除了构造函数和类型转换函数。

  • 传统上,在C和C++中,返回类型位于函数声明语句一开始的地方。然而,我们也可以在函数声明中把返回类型写在参数列表之后。例如

    1
    2
    std::string to_string(int a); // 前置返回类型
    auto to_string(int a) -> std::string; // 后置返回类型
  • 也就是说,前置的auto关键字表示函数的返回类型放在参数列表之后。后置返回类型由符号 -> 引导

  • 后置返回类型的必要性源于函数模板声明,因为其返回类型是依赖于参数的。例如

    1
    2
    template <class T, class U>
    auto product(const std::vector<T>& x, const std::vector<U>& y) -> decltype(x * y);
  • 如果函数调用了它自身,我们称之递归(recursive)

  • 函数可以包含多条return语句

  • 与参数传递的语义类似,函数返回值的语义也与拷贝初始化的语义一致。return语句初始化一个返回类型的变量。编译器检查返回表达式的类型是否与函数的返回类型吻合,并在必要时执行标准的或者用户自定义的类型转换。

12.1.7 [[noreturn]]函数

  • 形如[[…]]的概念被称为属性(attribute),属性可以置于C++语法的任何位置。通常情况下,属性描述了位于他前面的语法实体的性质,这些性质依赖于实现。
  • 此外,属性也能出现在声明语句开始的位置。C++只包含两个标准属性,[[noreturn]]和[[carries_dependency]]
  • 把[[noreturn]]放在函数声明语句的开始位置表示我们不希望该函数返回任何结果

12.1.8 局部变量

  • 定义在函数内部的名字通常称为局部名字(local name)。当线程执行到局部变量或者常量的定义出时,它们将被初始化。除非我们把变量声明为static,否则函数的每次调用都会拥有该变量的一份拷贝。
  • 相反,如果我们把局部变量声明为static,则在函数的所有调用中都将使用唯一的一份静态分配的对象,该对象在线程第一次到达它的定义处时被初始化。
  • static局部变量有一个非常重要的作用,即,它可以在函数的多次调用间维护一份公共信息而无须使用全局变量。如果使用了全局变量,则有可能会被其他不相干的函数访问甚至干扰。
  • 除非你进入了一个递归调用的函数或者发生了死锁,通常情况下,static局部变量的初始化不会导致数据竞争。也就是说,C++实现必须用某种无锁机制确保局部static变量的初始化被正确执行。递归的初始化一个局部static变量将产生未定义的结果。
  • static局部变量有助于避免非局部变量间的顺序依赖。

12.2 参数传递

  • 当程序调用一个函数时(使用后缀(),称为调用运算符 call operator或者应用运算符 application operator),我们为该函数的形参(formal arguments, 即 parameters)申请内存空间,并用实参(actual argument)初始化对应的形参。参数传递的语义与初始化的语义一致(严格地说是拷贝初始化)。
  • 编译器负责检查实参的类型是否与对应的形参类型吻合,并在非必要的时候执行标准类型转换或用户自定义的类型转换。除非形参是引用,其他情况下传入函数的是实参的副本。

12.2.1 引用参数

  • 引用传递的准确描述应该是左值引用传递,原因是函数不能接受一个右值引用作为它的参数
  • 该如何选择参数的传递方式?
    • 对于小对象使用值传递的方式
    • 对于你无需修改的大对象使用const引用传递
    • 如果需要返回计算结果,最好使用return而非通过参数修改对象
    • 使用右值引用实现移动和转发
    • 如果找不到合适的对象,则传递指针
    • 除非万不得已,否则不要使用引用传递
  • 在最后一条经验追责中,除非万不得已 是基于: 当我们需要修改对象的值时,传递指针比使用引用更容易表达清楚程序的原意

12.2.2 数组参数

  • 当数组作为函数的参数时,实际传入的是指向该数组首元素的指针
  • 数组类型的参数与指针类型的参数等价。

12.2.3 列表参数

  • 一个由{}限定的列表可以作为下述形参的实参
    • 类型std::initializer_list,其中列表的值能隐式的转换成T
    • 能用列表中的值初始化的类型
    • T类型数组的引用,其中列表的值能隐式的转换成T

12.2.4 数量未定的参数

  • 对于某些函数来说,很难明确指定调用时期望的参数数量和类型。要实现这样的接口,我们有三种选择
    • 使用可变模板:它允许我们以类型安全的方式处理任意类型,任意数量的参数。只要写一个小的模板元程序来解释参数列表的正确含义并采取适当的操作就可以了
    • 使用initializer_list作为参数类型。它允许我们以类型安全的方式处理某种类型的,任意数量的参数,在大多数上下文中,这种元素类型相同的参数列表是最常见和最重要的情形
    • 用省略号(…)结束参数列表,表示可能有更多的参数。它允许我们通过使用中的宏处理任意类型的,任意数量的参数。这种方案并非类型安全的,并且很难用于复杂的用户自定义类型。

12.2.5 默认参数

  • 一个通用的函数所需的参数通常比处理简单情况所需的参数要多。
  • 默认参数在函数声明时执行类型检查,在调用函数时求值。
  • 在同一个作用域的一系列声明语句中,默认参数不能重复或者改变。

12.3 重载函数

  • 大多数情况下我们应该给不同的函数起不一样的名字。但如果不同函数是在不同类型的对象上执行相同概念的任务,则给他们起同一个名字是更好的选择。为不同数据类型的同一种操作起相同的名字称为重载(overloading)
  • 对于编译器来说,同名函数唯一的共同点就是名字相同。
  • 模板为定义成组的重载函数提供了一种系统的方法

12.3.1 自动重载解析

  • 重载解析与函数声明的次序无关

12.3.2 重载与返回类型

  • 在重载解析过程中不考虑函数的返回类型,这样可以确保对运算符或者函数调用的解析独立于上下文

12.3.3 重载与作用域

  • 重载发生在一组重载函数集的成员内部,也就是说,重载函数应该位于同一个作用域内。不同的非名字空间作用域中的函数不会重载。
  • 基类和派生类提供的作用域不同,因此默认情况下基类函数和派生类函数不会发生重载
  • 如果我们希望实现跨类作用域或者名字空间作用域的重载,应该使用using声明或者using指示。

12.4 前置与后置条件

  • 我们把函数调用时应该遵循的约定称为前置条件(precondition),把函数返回时应该遵循的约定称为后置条件(postcondition)

12.5 函数指针

  • 与数据对象类似,由函数体生成的代码也置于某块内存区域中,因此它也有自己的地址。
  • 出于某些考虑,有的与机器体系结构有关,有的与系统设计有关,我们不允许函数指针修改所指的代码。
  • 程序员只能对函数做两种操作:调用它或者获取它的地址。通过获取函数地址得到的指针能被用来调用该函数。
  • 对于直接调用函数和通过指针调用函数这两种情况来说,参数传递的规则是一样的。
  • 函数指针为算法的参数化提供了一种途径。

12.6 宏

  • 宏在C语言中非常重要,但在C++中的作用就小得多了。关于宏的最重要的原则是:除非万不得已,否则不要使用宏。

12.6.1 条件编译

12.6.2 预定义宏

  • 编译器预定义了一些宏
    • __cplusplus: 在C++编译器中有定义(C语言编译器没有)。在C++11程序中它的值是201103L
    • DATA: “yyyy:mm:dd”格式的日期。
    • TIME: “hh:mm:ss”格式的时间
    • FILE: 当前源文件的名字
    • LINE: 当前源代码的代码行数
    • FUNC: 是一个由具体实现定义的C风格字符串,表示当前函数的名字
    • STDC_HOSTED: 如果当前实现是宿主式的,则为1;否则为0
    • STDC: 在C语言编译器中有定义(C++编译器中没有)

12.6.3 编译指令

  • 如果可能,尽量避免使用#pragma

12.7 建议

  • 把有用的操作打包在一起构成函数,然后认真起个名字
  • 一个函数应该对应逻辑上的一个操作
  • 让函数尽量短
  • 不要返回指向局部变量的指针或者引用
  • 如果函数必须在编译时求值,把他声明成constexpr
  • 如果函数无法返回结果,把他设置为 [[noreturn]]
  • 对小对象使用传值的方式
  • 如果你想传递无需修改的大值,使用传const引用的方式
  • 尽量通过return值返回结果,不要通过参数修改对象
  • 用右值引用实现移动和转发
  • 如果找不到合适的对象,可以传入指针
  • 除非万不得已,否则不要传递非const引用
  • const的用处广泛,程序元应该多用
  • 我们认为char或者const char参数指向的是C风格字符串
  • 避免把数组当成指针传递
  • 用initializer_list传递元素类型相同但是元素数量未知的列表
  • 避免使用数量未知的参数(…)
  • 当几个函数完成的功能在概念上一致,仅仅是处理的类型有区别时,使用重载。
  • 在整数类型上重载时,提供一些函数以消除二义性
  • 为你的函数指定前置条件和后置条件
  • 与函数指针相比,优先使用函数对象和虚函数
  • 不要使用宏
  • 如果必须使用宏,一定要用很多大写字母组成宏的名字,尽管这样的名字看起来会很丑陋

第十三章 异常处理

13.1 错误处理

  • 本章介绍如何用异常进行错误处理。我们必须基于一定的策略综合运用各项语言机制才能高效的处理错误。
  • 本章的内容主要分为两个部分
    • 一是异常安全保障(exception-safety guarantee),它是程序从运行时错误中快速恢复的关键;
    • 另一个是使用构造函数和析构函数进行资源管理的资源获取即初始化(Resource Acquisition Is Initialization, RAII)技术。
  • 因为异常安全保障和资源获取即初始化都依赖于不变式的规范,所以本章也会介绍一些关于强制断言的内容。

13.1.1 异常

  • 异常(exception)的概念可以帮助我们将信息从检测到错误的地方传递到处理该错误的地方。如果函数无法处理某个问题,则抛出异常,并且寄希望于函数的调用者能直接或者间接的处理该问题。函数如果希望处理某个问题,可以捕获(catch)相应的异常。
    • 主调组件如果想处理某些失败的情形,可以把这些异常置于try块的catch从句中
    • 被调组件如果无法完成既定的任务,可以用throw表达式抛出一个异常来说明这一情况。

13.1.2 传统的错误处理

  • 当函数检测到某个无法局部处理的问题并且必须向函数的调用者报告时,除了使用异常机制处理该错误,其他几种传统的处理方式都有各自的不足
    • 终止程序,这是一种非常极端的处理方式
    • 返回错误值。
    • 返回合法值,而程序却处于错误状态。问题是主调函数可能没有意识到程序已经处于错误状态了。例如,许多标准C语言库函数设置一个非局部变量error来表示错误。在应用并发机制时,用非局部变量记录错误状态的做法不太奏效
    • 调用错误处理函数。

13.1.3 渐进决策

  • 在异常处理模式中,对于未处理的错误(未捕获的异常)的最终响应是终止程序。

13.1.4 另一种视角看异常

  • C++的异常处理机制主要用于处理那些无法在局部范围内解决的问题。

13.7 建议

  • 在设计初期尽早确定异常处理策略
  • 当无法完成既定任务时抛出异常
  • 用异常机制处理错误
  • 不要试图捕获每个函数的每个异常
  • 抛出异常前先释放局部资源
  • 尽量减少使用try块
  • 不要让析构函数抛出异常
  • 把普通代码和异常处理代码分离开来

第十四章 名字空间

14.1 组合问题

  • 任何实际问题都是由若干独立部分组成的。函数和类提供了相对细粒度的关注点分离,而库,源文件和编译单元则提供了粗粒度的分离。
  • 逻辑上最理想的方式是模块化,即独立的事物保持分离,只允许通过良好定义的接口访问模块。
  • C++并不是通过单一语言特性来支持模块的概念,也并不存在模块这种语法构造。取而代之,C++通过其他语言特性(函数,类和名字空间)的组合和源码的组织来表达模块化。

14.2 名字空间

  • 名字空间(namespace)的概念用来直接表示本属一体的一组特性,例如库代码。
  • 一个名字空间应该表达某种逻辑结构:一个名字空间中的声明应该一起提供一些特性,使得用户看来它们是一个整体,而且能反应一组共同的设计策略。
  • 实际上,在一个名字空间中声明的实体是作为名字空间的成员被引用的。
  • 从名字空间外引用成员的其他方法包括using声明,using指示和参数依赖查找。

14.2.1 显式限定

  • 我们可以在名字空间的定义中声明一个成员,稍后用 名字空间名::成员名 的语法定义它
  • 一个名字空间形成一个作用域,通常的作用域规则也适用于名字空间。因此,名字空间是一个非常基础,非常简单的概念。程序规模越大,用名字空间表达程序的逻辑划分就越有用。全局作用域也是一个名字空间,可以显式的用 :: 来引用。例如
    1
    2
    3
    4
    5
    6
    7
    8
    int f();  // 全局函数

    int g()
    {
    int f; // 局部变量:屏蔽了全局函数
    f(); // 错误:不能调用一个整型变量
    ::f(); // 正确:调用全局函数
    }
  • 类也是名字空间

14.2.2 using声明

  • using声明将一个代用名引入了作用域,最好尽量保持代用名的局部性以避免混淆
  • 当用于一个重载的名字时,using声明会应用于其所有重载版本。例如
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    namespace N{
    void f(int);
    void f(string);
    };

    void g()
    {
    using N::f;
    f(789); // N::f(int)
    f("Bruce") // N::f(string)
    }

14.2.3 using指示

  • 在一个函数中,可以安全的使用using指示以方便符号表示,但是对全局using指示必须小心谨慎,因为过度使用using指示会导致名字冲突,而避免名字冲突恰恰是引入名字空间的目的。

14.2.5 名字空间是开放的

  • 名字空间是开放的:即,你可以从多个分离的名字空间声明中向一个名字空间添加名字。例如
    1
    2
    3
    4
    5
    6
    7
    8
    9
    namespace A 
    {
    int f();
    }

    namespace A
    {
    int g();
    }
  • 这样,名字空间的成员就不需要连续放置在单一的文件中。

14.5 建议

  • 用名字空间表达逻辑结构
  • 将除main()之外的所有非局部名字都置于名字空间中
  • 设计一个名字空间,以便能方便的使用它避免意外访问到不相关的名字空间
  • 不要为名字空间起非常短的名字
  • 如必要,使用名字空间别名为长名字空间提供简写
  • 不要给你的名字空间的使用者增加太多符号表示上的负担
  • 为接口和实现使用分离的名字空间
  • 当定义名字空间成员时使用 Namespace::member 表示方式
  • 用inline名字空间支持版本控制
  • 将using指示用于代码转换,用于基础库以及用于局部作用域
  • 不要将using指示放在头文件中。

第十五章

15.1 分离编译

  • 任何实际程序都由很多逻辑上分离的部分组成。为了更好的管理这些组成部分,我们可以将程序表示为一组源码文件,其中每个文件包含一个或多个逻辑组件。
  • 我们的任务是为程序设计一个文件集合,使得能以一种一致,易理解和灵活的方式表示这些逻辑组件。特别是,我们以接口(例如函数声明)与实现(例如函数定义)的完全分离为目标。
  • 当用户将一个源文件(source file)提交给编译器后,首先对文件进行预处理,即,处理宏以及将#include指令指定的头文件包含进来。预处理的结果称为编译单元(translation unit)。编译单元是编译器真正处理的内容,也是C++语言规则所描述的内容。
  • 链接器是将分离编译的多个部分绑定在一起的程序。编译器有时也被称为加载器(loader)。链接可以在程序开始运行前全部完成,但也可以在程序运行中将新代码添加进来–动态链接。
  • 程序的源文件组织通常称为其物理结构(physical structure)。程序的逻辑结构和物理结构不必相同。

15.2 链接

  • 除非已显式声明为局部名字,否则函数名,类名,模板名,变量名,名字空间名,枚举名以及枚举值名的使用必须跨所有编译单元保持一致。
  • 程序员必须保证每个名字空间,类,函数等必须在其出现的每个编译单元中都正确声明,且对应相同实体的声明都是一致的。
  • 对象在程序中只能定义一次,它可以声明很多次,但类型必须完全一致。
  • 如果全局作用域中或名字空间中的变量定义不带初始值,则该变量会使用默认初始值。非static局部变量或创建在自由存储上的对象则不会使用默认初始值。
  • 在类体外,实体必须先声明后使用。
  • 如果一个名字在其定义处之外的编译单元中也可以使用,我们称其具有外部链接(external linkage)。
  • 如果一个名字只能在其定义所在的编译单元中被引用,我们称其具有内部链接(internal linkage)
  • 在名字空间作用域,包括全局作用域中使用关键字static表示 不能再其他源文件中访问,即内部链接。
  • 关键字const按时默认内部链接。
  • 链接器看不到的名字,例如局部变量名,被称为无链接。
  • 默认情况下,名字空间中的const对象,constexpr对象,类型别名以及任何声明为static的实体都具有内部链接。
  • 为确保一致性,应该将别名,const对象,constexpr对象和inline函数放置在头文件中
  • 我们可以通过显式声明为一个const对象赋予外部链接。

15.2.1 文件内名字

  • 我们一般最好避免使用全局变量,因为这会引起维护问题。将变量放在名字空间中会有些帮助,但仍可能引起数据竞争
  • 如果必须使用全局变量,至少应限制它们只在单一源文件中使用,有两种方法实现这种限制
    • 将声明放在无名名字空间中
    • 声明实体时使用static
  • 使用无名名字空间可以令名字成为编译单元的局部名字。无名名字空间的效果非常像内部链接。

15.2.2 头文件

  • 同一个对象,函数,类等所有声明都要保持类型一致。因此,提交给编译器并随后链接在一起的源码必须保持一致。实现不同编译单元声明一致性的一种不完美但是很简单的方法是:在包含可执行代码或数据定义的源文件中 #include 包含接口信息的头文件
  • #include 机制是一种文本处理方式–将源程序片段收集起来形成单一的编译单元(文件)
  • 建议将简单常量定义放在头文件中,但不将集合定义放在头文件中,其原因是C++实现很难避免多个编译单元中重复的集合定义。
  • 使用 #include 时过分卖弄聪明是不明智的。我的建议是
    • 只 #include 头文件。不要#include包含变量定义和非inline函数的普通源码
    • 只 #include 完整的声明和定义
    • 只在全局作用域,链接说明块及名字空间定义中 #include 头文件
    • 将所有#include放在其他代码之前,以尽量减少无意造成的依赖关系
    • 避免使用宏技巧
    • 尽量减少在头文件中使用非局部的名字

15.2.3 单一定义规则

  • 每个给定类,枚举和模板等在程序中只能定义一次

15.2.4 标准库头文件

  • 标准库特性是通过一组标准库头文件提供的。

15.2.5 链接非C++代码

  • 因为C和C++关系紧密,extern “C” 指示特别有用。需要注意的是,extern “C”中的C表示的是链接规范而非语言。extern “C” 通常用于将函数链接到恰好符合C实现规范的Fortran和汇编程序。

15.4 程序

  • 一组分离编译的单元经由链接器组合就形成了程序。其中用到的每个函数,对象,类型等都必须是唯一定义的。一个程序必须恰好包含一个名为main()的函数。通过调用全局函数main()开始执行程序的主要计算任务,从main()返回后程序就终止了。main()的返回类型是int,所有C++实现都支持下面两个版本的main():
    1
    2
    int main() {}
    int main(int argc, char* argv[]) {}
  • main()返回的int作为程序执行的结果被传递给调用main()的系统,非零返回值表示发生了一个错误。

15.4.1 非局部变量初始化

  • 原则上,定义在任何函数之外的变量(即,全局变量,名字空间变量以及类static变量)在main()被调用前初始化。

15.4.3 程序终止

  • 程序终止的方式有很多种
    • 从main()返回
    • 调用exit()
    • 调用abort()
    • 抛出一个未捕获的异常
    • 违反noexcept
    • 调用quick_exit()
  • 如果使用标准库函数exit()终止一个程序,则会调用已构造的静态对象的析构函数。但是,如果程序是使用标准库函数abort()终止的,析构函数就不会被调用。
  • 注意,这意味着exit()不会立即终止程序。在一个析构函数中调用exit()会导致无限递归。
  • 函数exit(),abort(),quick_exit(),atexit(),at_quick_exit()都是在中声明的。

15.5 建议

  • 用头文件表达接口,强调逻辑结构
  • 在实现函数的源文件中 #include 声明函数的头文件
  • 不要在不同编译单元中定义同名但含义相近却不完全一致的全局实体
  • 不要再头文件中定义非内联函数
  • 只在全局作用域和名字空间中使用 #include
  • 只 #include 完整的声明
  • 使用包含保护
  • 在名字空间中 #include C头文件以避免全局名字
  • 令头文件自包含
  • 区分用户接口和实现者接口
  • 区分一般用户接口和专家用户接口
  • 若代码是用作非C++程序的一部分,则应避免需要运行时初始化的非局部对象。

第三部分 抽象机制

  • 这一部分介绍定义和使用新类型的C++特性,主要介绍通常称为面向对象程序设计和泛型程序设计的技术。

第十六章 类

16.1 引言

  • C++类是创建新类型的工具,创建出的新类型可以像内置类型一样方便的使用。而且,派生类和模板允许程序员表达类之间的关系并利用这种关系。
  • 一个类型就是一个概念,一个思想,一个观念等等的具体表示
  • 类是用户自定义类型。如果一个概念没有与之直接对应的内置类型,我们就定义一个新类型来表示它。
  • 定义新类型的基本思想是将实现的细节与正确使用它的必要属性分离。这种分离的最佳表达方式是:通过一个专用接口引导数据结构及其内部辅助例程的使用。

16.2 类基础

  • 下面是类的简要概括
    • 一个类就是一个用户自定义类型
    • 一个类由一组成员构成。最常见的成员类别是数据成员和成员函数
    • 成员函数可定义初始化,拷贝,移动和清理等语义
    • 对对象使用 . 访问成员,对指针使用 -> 访问成员
    • 可以为类定义运算符
    • 一个类就是一个包含其成员的名字空间
    • public成员提供类的接口,private成员提供实现细节
    • struct是成员默认为public的class

16.2.1 成员函数

  • 声明于类定义内的函数称为成员函数(member function),对恰当类型的特定变量使用结构成员访问语法才能调用这种函数。
  • 由于不同结构可能有同名成员函数,在定义成员函数时必须指定结构名
  • 在成员函数中,不必显式引用对象即可使用成员的名字.在此情况下,名字所引用的是调用函数的对象的成员.

16.2.2 默认拷贝

  • 默认情况下,对象是可拷贝的.特别是,一个类对象可以用同类的另一个对象的副本来进行初始化.例如:
    1
    2
    Data d1 = my_birthday;
    Date d2{my_birthday};
  • 默认情况下,一个类对象的副本是对每个成员逐个拷贝得到的.
  • 类似的,类对象默认也可以通过赋值操作拷贝.
  • 再重复一边,默认的拷贝语义是逐成员复制的.如果对于类这不是正确的选择,用户可以定义一个恰当的赋值运算符

16.2.3 访问控制

  • 标签public将类的主体分为两部分.
    • 第一部分中的名字是私有的(private),它们只能被成员函数使用.
    • 第二部分是公有的(public),构成类对象的公共接口.
  • struct就是一个成员默认为公有的class,成员函数的声明和使用是一样的.
  • 但是,非成员函数禁止使用私有成员.

16.2.4 class和struct

  • 下面的语法结构
    • class X {…};
  • 称为类定义(class definition),它定义了一个名为X的类型.由于历史原因,类定义常常被称为类声明.
  • 如果我认为一个类是简单数据结构,更喜欢使用struct.
  • 如果我认为一个类是 具有不变式的真正类型,会使用class.
  • C++并不要求在类定义中首先声明数据.实际上,将数据成员放在最后以强调提供公共用户接口的函数(位置在前)通常是很有意义的.例如
    1
    2
    3
    4
    5
    6
    7
    8
    class Date3 
    {
    public:
    Date3(int dd, int mm, int yy);
    void add_year(int n);
    private:
    int d, m, y;
    }

16.2.5 构造函数

  • 构造函数的本质是构造一个给定类型的值.构造函数的显著特征是与类具有相同的名字.
  • 如果一个类有一个构造函数,其所有对象都会通过调用构造函数完成初始化.如果构造函数需要参数,在初始化时就要提供这些参数.
  • 由于构造函数定义了类的初始化方式,因此我们可以使用{}初始化记法
  • 我建议优先使用{}记法而不是(),因为前者明确表明了要做什么(初始化),从而避免了某些潜在错误.而且可以一致的使用.
  • 通过提供多个构造函数,可以为某类型的对象提供多种不同的初始化方法.
  • 构造函数的重载规则与普通函数相同.只要构造函数的参数类型明显不同,编译器就能选择正确的版本使用.
  • 注意,通过确保对象的正确初始化,构造函数极大的简化了成员函数的实现.有了构造函数,其他成员函数就不再需要处理未初始化数据的情况.

16.2.6 explicit构造函数

  • 默认情况下,用单一参数调用一个构造函数,其行为类似于从参数类型到类自身类型的转换.
  • 我们可以指定构造函数不能用作隐式类型转换.如果构造函数的声明带有关键字explicit,则它只能用于初始化和显示类型转换.
  • 用=进行初始化可看作拷贝初始化(copy initialization).一般来说,初始化器的副本会被放入待初始化的对象.
  • 省略=会将初始化变为显式初始化.显式初始化也称为直接初始化(direct initialization)
  • 默认情况下,应该将单参数的构造函数声明为explicit.
  • 如果一个构造函数声明为explicit且定义在类外,则在定义中不能重复explicit
    1
    2
    3
    4
    5
    6
    7
    8
    9
    class Date 
    {
    int d, m, y;
    public:
    explicit Date(int dd);
    };

    Date::Date(int dd) {...} // 正确
    explicit Date(int dd) {...} // 错误
  • 大多数explicit起很重要作用的构造函数都接受单一参数.但是,explicit也可用于无参或多个参数的构造函数.
  • 列表初始化也存在直接初始化和拷贝初始化的区别.

16.2.7 类内初始化器

  • 当使用多个构造函数时,成员初始化可以是重复的。

16.2.8 类内函数定义

  • 如果一个函数不仅在类中声明,还在类中定义,那么它就被当作内联函数处理,即很少修改且频繁使用的小函数适合类内定义。
  • 类成员可以访问同类的其他成员,而不管成员是在哪里定义的。即函数和数据成员的声明是不依赖于顺序的。

16.2.13 成员类型

  • 类型和类型别名也可以作为类的成员。
  • 成员类可以引用其所属类的类型和static成员。当给定所属类的一个对象时,只能引用非static成员。

16.3 具体类

  • C++直接支持一部分抽象作为其内置类型。但是,大多数抽象并不直接支持 。
  • 如果一个类的表示是其定义的一部分,我们就称它是具体的(concrete,或者称它是一个具体类)。这将与抽象类区分开来,后者为多种实现提供一个公共接口。在定义中明确类的表示方式令我们能:
    • 将对象置于栈,静态分配的内存以及其他对象中
    • 拷贝和移动对象
    • 直接引用具名对象

16.3.2 辅助函数

  • 一般而言,一个类都会有一些无须定义在类内的关联函数,因为它们不需要直接访问类的表示。

16.3.3 重载运算符

  • 添加一些函数使得用户自定义类型能使用人们习惯的符号通常是很有用的
  • 注意,赋值和拷贝初始化是默认提供的。

16.3.4 具体类的重要性

  • 具体类的使用就像内置类型一样。具体类型也称为值类型(value type),使用它们编程称为面向值的程序设计。
  • 一个具体类型的目标是高效的做好一件相对简单的事情,为用户提供修改其行为的特性通常不是其目标。特别是,展现运行时多态行为也不是其意图。

16.4 建议

  • 将概念表示为类
  • 将类的接口与实现分离
  • 仅当数据真的仅仅是数据且数据成员不存在有意义的不变式时才使用共有数据(struct)
  • 定义构造函数来处理对象初始化
  • 默认将单参数构造函数声明为explicit
  • 将不修改其对象状态的成员函数声明为const
  • 具体类型是最简单的类。只要适用,就应该优先选择具体类型而不是更复杂的类或普通数据结构。
  • 仅当函数需要直接访问类的表示时才将其实现为成员函数。
  • 使用名字空间建立类与其辅助函数间的显示关联
  • 将不修改对象值的成员函数定义为const成员函数。
  • 若一个函数需要访问类的表示,但并不需要用某个具体对象来调用,建议将其实现为static成员函数

第十七章 构造,清理,拷贝和移动

17.1 引言

  • 本章主要介绍与对象的生命周期有关的技术:
    • 我们如何创建对象
    • 如何拷贝对象
    • 如果移动对象
    • 在对象销毁时如何进行清理工作
  • 移动和拷贝的区别在于,拷贝操作后两个对象具有相同的值,而移动操作后移动源不一定具有原始值。如果源对象在操作后不再使用,我们就可以使用移动操作。
  • 一个对象在6中情况下会被拷贝或移动
    • 作为赋值操作的源
    • 作为一个对象初始化器
    • 作为一个函数实参
    • 作为一个函数返回值
    • 作为一个异常
  • 在所有这些情况下,都会应用拷贝或移动构造函数
  • 除了初始化具名对象和自由存储上的对象,构造函数还用来初始化临时对象。

17.2 构造函数和析构函数

  • 我们可以通过定义一个构造函数来指出一个类的对象应如何初始化。与构造函数对应,我们还可以定义一个析构函数来确保对象销毁时进行恰当的清理操作。

17.2.1 构造函数和不变式

  • 与类同名的成员称为构造函数。构造函数的声明指出其参数列表,但未指出返回类型。
  • 构造函数的任务是初始化该类的一个对象。一般而言,初始化操作必须建立一个类不变式,所谓不变式就是当成员函数(从类外)被调用时必须保持的某些东西
  • 为什么应该定义一个不变式呢?这是为了
    • 聚焦于类的设计工作上
    • 理清类的行为
    • 简化成员函数的定义
    • 理清类的资源管理
    • 简化类的文档
  • 通常,设计不变式最终会节省我们的总工作量

17.2.2 析构函数和资源

  • 构造函数初始化对象。换句话说,它创建供成员函数进行操作的环境。
  • 这种基于构造函数/析构函数的资源管理风格被称为资源获取即初始化或简称RAII
  • 一对匹配的构造函数/系统函数是C++中实现可变大小对象的常用机制。

17.2.3 基类和成员析构函数

  • 构造函数和析构函数可以很好的与类层次配合。构造函数会自顶向下的创建一个类对象
    • 首先,构造函数调用其基类的构造函数
    • 然后,它调用成员的构造函数
    • 最后,它执行自身的函数体。
  • 析构函数则按相反顺序拆除一个对象。
    • 首先,析构函数执行自身的函数体
    • 然后,它调用其成员的析构函数
    • 最后,它调用其基类的析构函数。
  • 特别是,一个virtual基类必须在任何可能使用它的基类之前构造。

17.2.4 调用构造函数和析构函数

  • 当对象退出作用域或被delete释放时,析构函数会被隐式调用。显式调用析构通常是不必要的,而且会导致严重的错误。

17.3 类对象初始化

  • 本节讨论如何初始化一个类的对象,分使用构造函数和不适用构造函数两种情况讨论

17.3.1 不适用构造函数进行初始化

  • 我们不能为内置类型定义构造函数,但能用一个恰当类型的值初始化内置类型对象。例如
    1
    2
    int a{1};
    char* p{nullptr};
  • 类似的,我们可以用下列方法初始化一个无构造函数的类的对象
    • 逐成员初始化
    • 拷贝初始化
    • 默认初始化,不用初始化器或空初始化列表

17.3.2 使用构造函数进行初始化

  • 当逐成员拷贝不能满足需求时,我们可以定义构造函数来初始化对象。特别是,构造函数常用来建立类的不变式并获取必要的资源。
  • 注意,当定义了一个接受参数的构造函数后,默认构造函数就不存在了
  • 我使用{}语法来明确表示正在进行初始化,而不仅仅是在赋值,调用函数或是声明函数.只要是在构造对象的地方,我们都可以用{}初始化语法为构造函数提供参数.
  • 处于这个原因,{}初始化有时也称为通用(universal)初始化:
    • 这种语法可以在任何地方,
    • 而且,{}初始化还是一致的:无论你在哪里使用语法{v}将类型X的对象初始化为值v,都会创建相同的值
  • 与{}相反,=和()初始化语法不是通用的.
  • 注意,{}初始化器语法不允许窄化转换.这是我们更倾向于使用{}风格而不是()或=的另一个原因.

17.3.2.1 用构造函数进行初始化

  • 使用 ()语法, 可以请求在初始化过程中使用一个构造函数.即,对一个类,你可以保证用构造函数进行初始化而不会进行{}语法也提供的逐成员初始化或初始化器列表初始化.
  • {}初始化的一致使用自C++11起才成为现实

17.3.3 默认构造函数

  • 无参的构造函数被称为默认构造函数.
  • 如果构造对象时未指定参数或提供了一个空初始化器列表,则会调用默认构造函数.
  • 内置类型被认为具有默认构造函数和拷贝构造函数.但是,对于内置类型的未初始化的非static变量,其默认构造函数不会被调用.内置整数类型的默认值为0,浮点类型的默认值为0.0,指针类型的默认值为nullptr
  • 引用和const必须被初始化.

17.3.4 初始化器列表构造函数

  • 接受单一std::initializer_list参数的构造函数被称为初始化器列表构造函数.
  • 一个初始化器列表构造函数使用一个{}列表作为其初始化值来构造对象.
  • 标准库容器都有初始化器列表构造函数,初始化器列表赋值运算符等成员.
  • 我们想要使用接受一个{}列表进行初始化的机制,就要定义一个接受std::initializer_list类型参数的函数,通常是一个构造函数.

17.3.4.1 initializer_list构造消除歧义

  • 如果一个类已有多个构造函数,则编译器会使用常规的重载解析规则根据给定参数选择一个正确的构造函数.当选择构造函数时,默认构造函数和初始化器列表构造函数优先.具体规则如下
    • 如果默认构造函数或初始化器列表构造函数都匹配,优先选择默认构造函数
    • 如果一个初始化器列表构造函数和一个普通构造函数都匹配,优先选择列表初始化器构造函数.

17.3.4.2 使用initializer_list

  • 可以将接受一个initializer_list参数的函数作为一个序列来访问,即,通过成员函数begin(), end()和size()访问.
  • 不幸的是,initializer_list不提供下标操作
  • initializer_list是以传值方式传递的.这是重载解析规则所要求的,而且不会带来额外开销,因为一个initializer_list对象只是一个小句柄,通常是两个字大小,指向一个元素类型为T的数组
  • initializer_list的元素是不可变的,不要考虑修改它们的值

17.3.4.3 直接和拷贝初始化

  • {}初始化也存在直接初始化和拷贝初始化的区别.对一个容器来说,这意味着这种区别对容器自身及其中的元素都有作用:
    • 容器的初始化器列表构造函数可以是explicit,也可以不是
    • 初始化器列表的元素类型的构造函数可以是explicit,也可以不是.

17.4 成员和基类初始化

  • 构造函数可以建立不变式并获取资源.一般而言,构造函数是通过初始化类成员和基类来完成这些工作的.

17.4.1 成员初始化

  • 在构造函数的定义中,通过成员初始化器列表给出成员的构造函数的参数.例如
    1
    2
    3
    4
    5
    Club::Club(const string& n, Data fd)
    : name{n}, members{}, officers{}, founder{fd}
    {

    }
  • 成员初始化器列表以一个冒号开始,后面的成员初始化器用逗号间隔.
  • 类自身的构造函数在其函数体执行之前会先调用成员的构造函数.
  • 成员的构造函数按成员在类中声明的顺序调用,而不是按成员在初始化器中列表中出现的顺序.
  • 为了避免混淆,最好按成员的声明顺序指明初始化器.
  • 一个构造函数可以初始化其类的成员和基类,但不会初始化其成员或基类的成员或基类.

17.4.2 基类初始化器

  • 派生类的基类的初始化方式与非数据成员相同.即,如果基类要求一个初始化器,我们就必须在构造函数中提供相应的基类初始化器.
  • 与成员初始化类似,基类按声明顺序进行初始化,建议按此顺序指定基类的初始化器.基类的初始化在成员之前,销毁在成员之后.

17.4.3 委托构造函数

  • 如果你希望两个构造函数做相同的操作,可以重复代码,也可以定义一个 init()函数 来执行两者相同的操作.
  • 一种替代方法是用一个构造函数定义另一个
    1
    2
    3
    4
    5
    6
    7
    8
    class X 
    {
    int a;
    public:
    X(int x) {if (0 < x && x <= max) a = x; else throw Bad_X(x);}
    X() : X(24){}
    X(string s):X(to<int>(s)){}
    }
  • 即,使用一个成员风格的初始化器,但用的是类自身的名字(也是构造函数名),它会调用另一个构造函数,作为这个构造过程的一部分.这样的构造函数称为委托构造函数(delegating constructor,有时也称为转发构造函数, forwarding constructor)

17.4.4 类内初始化器

  • 我们可以在类声明中为非static数据成员指定初始化器.例如
    1
    2
    3
    4
    5
    6
    class A 
    {
    public:
    int a {77};
    int b = 88;
    };
  • 处于语法分析和名字查找相关的很隐蔽的技术原因,{}和=语法能用于类内成员初始化器,但是 ()语法就不行
  • 一个类内初始化器可以使用它的位置所在作用域中的所有名字.

17.4.5 static成员初始化

  • 一个static类成员是静态分配的,而不是每个类对象的一部分.一般来说,static成员声明充当类外定义的声明.例如
    1
    2
    3
    4
    5
    class Node 
    {
    static int node_count; // 声明
    };
    int Node::node_count = 0; // 定义
  • 但是,在少数简单的特殊情况下,在类内声明中初始化static成员也是可能的.条件是 static成员必须是整型或枚举类型的const,或字面值类型的constexpr,且初始化器必须是一个常量表达式.例如
    1
    2
    3
    4
    5
    6
    7
    8
    9
    class Curious 
    {
    public:
    static const int c1 = 7; // 正确
    static int c2 = 11; // 错误:非const
    const int c3 = 13; // 正确,但非static
    static const int c4 = sqrt(9); // 错误:类内初始化器不是常量
    static const float c5 = 7.0; // 错误:类内初始化成员不是整型,应该使用constexpr而非const
    };
  • 当且仅当你使用一个已初始化成员的方式要求它像对象一样在内存中存储时,该成员必须在某处定义.初始化器不能重复;
  • 成员常量的主要用途是为类声明中其他地方用到的常量提供符号名称.

17.5 拷贝和移动

  • 当我们需要从a到b传输一个值的时候,通常有两种逻辑上不同的方法
    • 拷贝(copy)是 x = y 的常规含义:即,结果是x和y的值都等于赋值前y的值
    • 移动(move)将x变为y的旧值,y变为某种移出状态(moved-from state).我们最感兴趣的情况–容器,移出状态就是 空
  • 一般来说,移动操作不能抛出异常,而拷贝操作则可以.因为拷贝可能需要获取资源,移动操作通常比拷贝操作更高效.
  • 为了避免乏味的重复性工作,拷贝和移动操作都有默认定义

17.5.1 拷贝

  • 类X的拷贝操作有两种

    • 拷贝构造函数: X(const X&)
    • 拷贝赋值运算符: X& operator=(const X&)
  • 拷贝构造函数与拷贝赋值运算符的区别在于前者初始化一片未初始化的内存,而后者必须正确处理目标对象已构造并可能拥有资源的情况.

  • 从拷贝的目的来看,一个基类就是一个成员:为了拷贝派生类的一个对象,你必须拷贝其基类

17.5.2 移动

  • 移动赋值背后的思想是将左值的处理与右值的处理分离:拷贝赋值操作和拷贝构造函数接受左值,而移动赋值操作和移动构造函数则接受右值。对于return值,采用移动构造函数。

17.6 生成默认操作

  • 编写拷贝操作,析构函数这样的常规操作会很乏味也容易出错,因此需要时编译器可为我们生成这些操作。默认情况下,编译器会为一个类生成
    • 一个默认构造函数:X()
    • 一个拷贝构造函数: X(const X&)
    • 一个拷贝赋值运算符:X& operator=(const X&)
    • 一个移动构造函数:X(X&&)
    • 一个移动赋值运算符: X& operator=(X&&)
    • 一个析构函数:~X()

17.6.1 显式声明默认操作

  • 使用 =default 总是比你自己实现默认语义要好。

17.6.2 默认操作

  • 每个生成的操作的默认含义,像编译器生成它们所用的实现方法一样,就是对类的每个基类和非static数据成员应用此操作。即,逐成员拷贝,逐成员默认构造等等。

17.6.4 使用delete删除的函数

  • 我们可以删除一个函数,即,我们可以声明一个函数不存在,从而令(隐式或显式)使用它的尝试成为错误。
  • 这种机制最明显的应用是消除其他默认函数。例如,防止拷贝基类是很常见的,因为这种拷贝容易导致切片。

17.7 建议

  • 应该构造函数,赋值操作以及析构函数设计为一组匹配的曹祖
  • 使用构造函数为类建立不变式
  • 如果一个构造函数获取了资源,那么这个类就需要一个析构函数释放该资源
  • 如果一个类有虚函数,它就需要一个虚析构函数
  • 如果一个类没有构造函数,它可以进行逐成员初始化
  • 优先选择使用{}初始化而不是=和()初始化
  • 当且仅当类对象有 自然的 默认值时才为类定义默认构造函数
  • 如果一个类是容器,为它定义一个初始化列表构造函数
  • 按声明顺序初始化成员和基类
  • 如果一个类有一个引用成员,它可能需要拷贝操作
  • 在构造函数中优先选择成员初始化而不是赋值操作
  • 使用类内初始化器来提供默认值
  • 如果一个类是一个资源句柄,它可能需要拷贝和移动操作
  • 当编写一个拷贝构造函数时,小心拷贝每个需要拷贝的元素
  • 一个拷贝操作应该提供等价性和独立性
  • 小心纠缠的数据结构
  • 优先选择移动语义和写前拷贝而不是浅拷贝
  • 如果一个类被用作基类,防止切片现象
  • 如果一个类需要一个拷贝操作或一个析构函数,它可能需要一个构造函数,一个析构函数,一个拷贝赋值操作以及一个拷贝构造函数
  • 如果一个类有一个指针成员,它可能需要一个析构函数和非默认拷贝操作

第十八章 运算符重载

18.1 引言

  • 运算符重载最常用于数字类型,但是用户自定义运算符的用处绝不仅仅局限于数字类型。

18.5 建议

  • 定义运算符时应该尽量模仿传统用法
  • 如果默认的拷贝操作对于某种类型不适用,应该重新定义或者干脆禁用
  • 对于较大的运算对象,选用const引用类型
  • 对于较大的返回结果,选择移动构造函数
  • 对于需要访问类的表示部分的操作,优先将其定义为成员函数
  • 反之,对于无须访问类的表示部分的操作,优先将其定义为非成员函数
  • 用名字空间把辅助函数和它们的类结合在一起
  • 把对称的运算符定义成非成员函数
  • 用用户自定义的字面值常量模仿传统用法
  • 不要轻易为数据成员提供 set() 和 get() 函数,除非从语义上确使需要它们
  • 谨慎使用隐式类型转换
  • 避免使用丢失部分信息的类型转换
  • 对于同一种类型转换,切勿把它同时定义成构造函数以及类型转换运算符。

第十九章 特殊运算符

19.2 特殊运算符

  • 下列运算符
    • [] () -> ++ – new delete
    • +, <, ~等传统的一元或者二元运算符相比有其特殊之处,主要是从这些运算符在代码中的使用到程序员给出的定义的映射与传统运算符有轻微的差别。

19.2.1 取下标

  • 我们可以用operator[]函数为类对象的下标赋予某种新的含义。operator[]函数的第二个参数可以是任意类型的,因此,它常被用于定义vector,关联数组等类型
  • operator必须是非static成员函数

19.2.2 函数调用

  • 函数调用可以看成是一个二元运算,它的左侧运算对象是expression,右侧运算对象是expression-list。调用运算符()可以像其他运算符一样被重载。
  • 运算符() 最直接也是最重要的目标是为某些行为类似函数的对象提供函数调用语法。其中,行为模式与函数类似的对象称为类函数对象(function-like object)或者简称为函数对象。
  • 函数调用运算符通常是模板

19.2.3 解引用

  • 解引用运算符 -> 可以定义成一个一元后置运算符
  • 重载->的主要目的是创建 智能指针,即,行为与指针类似的对象

19.2.4 递增和递减

  • 在C++的所有运算符中,递增运算符和递减运算符是最特别的,因为它们即可以作为前置运算符,也可以作为后置运算符。
  • 前置递增运算符返回对象的引用,后置递增预算符返回一个新创建的对象。

19.2.5 分配和释放

  • 运算符new通过调用operator new()分配内存。相应的,运算符delete通过调用operator delete()释放内存。

19.2.6 用户自定义字面值常量

  • C++为内置数据类型提供了字面值常量
    1
    2
    3
    4
    5
    6
    7
    123;  // int
    1.2; // double
    1.2F; // float
    'a'; // char
    1ULL; // unsigned long long
    0xD0; // 十六进制 unsigned
    "as"; // C风格字符串
  • 我们也能为用户自定义类型提供字面值常量,或者更新内置类型字面值常量的形式。例如
    1
    2
    3
    4
    5
    "Hi"s // 字符串,并非以0结尾的字符数组
    1.2i // 虚数
    1010101111001100b // 二进制数
    123s // 秒数
    123.56km // 注意此处并非miles
  • 上述的用户自定义字面值常量是通过字面值常量运算符定义的,这类运算符负责把带后缀的字面值常量映射到目标类型。字面值常量运算符的名字由operator””加上后缀组成

19.4 友元

  • 一条普通的成员函数声明语句在逻辑上包含相互独立的三层含义
    • 该函数有权访问类的私有成员
    • 该函数位于类的作用域之内
    • 我们必须用一个含有this指针的对象调用该函数
  • 通过把成员函数声明成static的,我们可以令它只具有前两层含义。
  • 通过把非成员函数声明成friend的,我们可以令它只具有第一层含义。换句话说,一个friend函数可以像成员函数一样访问类的实现,但是在其他层面上与类是完全独立的。
  • 通常情况下,我们可以选择把类设计为成员(嵌套的类)或者非成员的友元

19.4.1 发现友元

  • 友元必须在类的外层作用域中提前声明,或者定义在直接外层非类作用域中。
  • 友元函数应该显式的声明在外层作用域中,或者接收一个数据类型为该类或者其派生类的参数;否则我们无法调用该友元函数。

19.4.2 友元与成员

  • 到底应该何时使用友元函数,何时把操作定义为成员函数呢?
  • 首先,我们应该让有权访问类的表示的函数数量尽可能少,并且确保所选的访问函数准确无误。

19.5 建议

  • 用operator执行取下标以及通过单个值查询等操作
  • 用operator()()执行函数调用,取下标以及通过多个值查询等操作
  • 用operator->()解引用 智能指针
  • 前置++优于后置++
  • 除非万不得已,否则不要定义全局operator new(), operator delete()
  • 为特定类或者类层次体系定义成员函数operator new() 和 operator delete(),用他们分配和释放内存空间
  • 用用户自定义的字面值常量模仿人们习惯的语法表示
  • 在大多数应用场合,建议使用标准库string而非你自己的版本
  • 如果需要使用非成员函数访问类的表示,比如改进写法,或者同时访问两个类的表示,把它声明成类的友元。
  • 当需要访问类的实现时,优先选用成员函数而非友元函数

第二十章 派生类

20.1 引言

  • C++从Simula借鉴了类和类层次的思想。而且,C++还借鉴了一个重要的设计思想:类应该用来建模程序员和应用程序世界中的思想。
  • C++提供了派生类的概念及相关的语言机制来表达层次关系,即,表达类之间的共性。
  • C++语言特性支持从已有类构建新的类
    • 实现继承(implementation inheritance): 通过共享基类所提供的特性来减少实现工作量
    • 接口继承(interface inheritance): 通过一个公共基类提供的接口允许不同派生类互换使用
  • 接口继承常被称为运行时多态(run-time polymorphism, 或者动态多态, dynamic polymorphism)。
  • 相反,模板所提供的类的通用性和继承无关,常被称为编译时多态(compile-tile polymorphism, 或静态多态, static polymorphism)

20.2 派生类

  • 派生关系通常可以图示为从派生类到其基类的一个箭头,表示派生类引用其基类。
  • 我们常常称一个派生类继承了来自基类的属性,因此这种关系也称为继承(inheritance)。有时,基类也称为超类(superclass),派生类称为子类(subclass)
  • 但是,派生类对象中的数据是其基类对象数据的超集。一个派生类通常比基类保存更多数据,提供更多函数,从这一点来说它比基类更大,绝不会更小。
  • 派生类概念的一种流行且高效的实现是将派生类对象表示为基类对象,再加上那些专属于派生类的信息放在末尾。
  • 派生一个类没有任何内存额外开销,所需内存就是成员所需空间。
  • 换句话说,若通过指针和引用进行操作,派生类对象可以当作其基类对象处理,反过则不能。
  • 将一个类用作基类等价于定义一个该类的(无名)对象。因此,类必须定义后才能用作基类。
    1
    2
    3
    4
    class Employee; // 只是声明,不是定义
    class Manager : public Employee { // 错误:Employee未定义
    ...
    };

20.2.1 成员函数

  • 派生类的成员可以使用基类的公有和保护成员,就好像它们声明派生类中一样。但是派生类不能访问基类的私有成员。
  • 通常,对派生类而言最干净的解决方案是只使用其基类的公有成员。

20.2.2 构造函数和析构函数

  • 构造函数和析构函数照例是必不可少的
    • 对象自底向上构造(基类先于成员,成员先于派生类),自顶向下销毁(派生类先于成员,成员先于基类)
    • 每个类都可以初始化其成员和基类(但不能直接初始化其基类的成员或基类的基类)
    • 类层次中析构函数通常应该是virtual的
    • 类层次中类的拷贝构造函数须小心使用,以壁面切片现象
    • 虚函数调用的解析,dynamic_cast,以及构造函数或析构函数中的typeid()反映了构造和析构的阶段(而不是尚未构造完成的对象的类型)

20.3 类层次

  • 一个派生类自身也可以作为其他类的基类。例如
    1
    2
    3
    class Employee {/*...*/};
    class Manager : public Employee {/*...*/};
    class Director : public Manager {/*...*/};
  • 我们习惯称这样一组相关的类为类层次(class hierarchy)。这种层次结构大多数情况下是一棵树,但也可能是更一般的图结构

20.3.1 类型域

  • 为了使派生类不至于成为仅仅是一种方便的声明简写方式,我们必须解决一个问题:给定一个Base*类型的指针,它指向的对象的真正派生类型是什么?
  • C++提供了四种基本解决方法
    • 保证指针只能指向单一类型的对象
    • 在基类中放置一个类型域,供函数查看
    • 使用dynamic_cast
    • 使用虚函数
  • 除非使用final,否则方法1依赖于所使用类型的很多知识。

20.3.2 虚函数

  • 虚函数机制允许程序员在基类中声明函数,然后在每个派生类中重新定义这些函数,从而解决了类型域方法的固有问题。编译器和链接器会保证对象和施用于对象之上的函数之间的正确关联。

  • 关键字virtual指出print()作为这个类自身定义的print()函数及其派生类中定义的print()函数的接口。

  • 为了允许一个虚函数声明能作为派生类中定义的函数的接口,派生类中函数的参数类型必须与基类中声明的参数类型完全一致,返回类型也只允许细微改变。虚成员函数有时也称为方法(method)

  • 首次声明虚函数的类必须定义它(除非虚函数被声明为纯虚函数)

  • 即使没有派生类,也可以使用虚函数,而一个派生类如果不需要自有版本的虚函数,可以不定义它。当派生一个类时,如需要某个函数,定义恰当版本即可。

  • 如果派生类中一个函数的名字和参数类型与基类中的一个虚函数完全相同,则称它覆盖(override)了虚函数的基类版本。此外,我们也可以用一个派生类层次更深的返回类型覆盖基类中的虚函数。

  • 除了我们显式说明调用虚函数的哪个版本(例如Employee::print())之外,覆盖版本会作为最恰当的选择应用于调用它的对象。无论用哪个基类访问对象,虚函数调用机制都会保证我们总是得到相同的函数。

  • 无论真正使用的确切Employee类型是什么,都能令Employee的函数表现出正确的行为,这称为多态性(polymorphism)。具有虚函数的类型称为多态类型(polymorphic type)或者说是 运行时多态类型(run-time polymorphic type)

  • 在C++中为了获得运行时多态行为,必须调用virtual成员函数,对象必须通过指针或引用进行访问。当直接操作一个对象时(而不是通过指针或引用),编译器了解其确切类型,从而就不需要运行时多态了。

20.3.3 显式限定

  • 使用作用域解析运算符::调用函数能保证不适用virtual机制

20.3.4 覆盖控制

  • 特定的控制机制
    • virtual: 函数可能被覆盖
    • =0:函数必须是virtual的,且必须被覆盖
    • override: 函数要覆盖基类中的一个虚函数
    • final: 函数不能被覆盖

20.3.5 using基类成员

  • 函数重载不会跨作用域

20.3.6 返回类型放松

  • 覆盖函数的类型必须与它所覆盖的虚函数的类型完全一致,C++对这一规则提供了一种放松机制。即,如果原返回类型为B,则覆盖函数的返回类型可以为D,只要B是D的一个公有基类即可。类似的,返回类型B&可以放松为D&。这一规则有时称为协变返回规则(covariant return)

20.4 抽象类

  • 具有一个或多个纯虚函数的类称为抽象类(abstract class)。我们无法创建抽象类的对象
  • 抽象类就是要作为通过指针和引用访问的对象的接口(为保持多态行为)。因此,对一个抽象类来说,定义一个虚析构函数通常很重要。由于抽象类提供的接口不能用来创建对象,因此抽象类通常没有构造函数。
  • 抽象类只能用作其他类的接口。抽象类提供接口,但不暴露实现细节。
  • 抽象类所支持的设计风格称为接口继承(interface inheritance),它与实现继承(implementation inheritance)相对,后者是由带状态或定义了成员函数的基类所支撑的。两种风格组合使用是可能的。

20.5 访问控制

  • 一个类成员可以是 private, protected或public的
    • 如果它是private的,仅可被所属类的成员函数和友元函数所使用
    • 如果它是protected的,仅可被所属类的成员函数和友元函数以及派生类的成员函数和友元函数所使用
    • 如果它是public的,可被任何函数所使用
  • 这反映了函数按类访问权限可分为三类:
    • 实现类的函数(其友元和成员)
    • 实现派生类的函数(派生类的友元和成岩)
    • 以及其他函数

20.5.1 protected成员

  • 当设计一个类层次时,有时我们提供的函数是供派生类的实现者而非普通用户所用的。

  • 例如,我们可能为派生类实现者提供一个高效的,不进行检查的访问函数,为其他人提供一个安全的进行检查的访问函数。我们可以通过将不检查的版本声明为protected来达到这一目的。

  • 在类中,成员默认是private的,而这通常是更好的选择。以我的经验,总是有其他代替方法,从而无须将派生类要用到的大量数据都放到一个公共基类中。

20.5.2 访问基类

  • 类似成员,基类也可以声明为private,protected或public。例如
    1
    2
    3
    class X: public B {/*...*/};
    class Y: protected B {/*...*/};
    class Z: private B {/*...*/};
  • 不同的访问说明符满足不同设计需求
    • public派生令派生类称为基类的一个子类型。例如,X是一种B。这是最常见的派生形式
    • private基类最有用的情形就是当我们定义一个类时将其接口限定为基类,从而可提供更强的保障。
    • protected基类在类层次中很有用,其中进一步的派生是常态。类似private派生,protected派生也用于表示实现细节。

20.7 建议

  • 避免使用类型域
  • 通过指针和引用访问多态对象
  • 使用抽象类,以便聚焦于清晰接口的设计应该提供什么
  • 在大型类层次中用override显式说明覆盖
  • 谨慎使用final
  • 使用抽象类说明接口
  • 使用抽象类保持实现细节和接口分离
  • 如果一个类有虚函数,那么它也应该有一个虚析构函数
  • 抽象类通常不需要构造函数
  • 优先选择private成员用于类的细节实现
  • 优先选择public成员用于接口
  • 仅在确实需要时才使用protected成员,且务必小心使用
  • 不要将数据成员声明为protected

第二十一章 类层次

21.4 建议

  • 为了避免忘记delete用new创建的对象,建议使用unique_ptr或者shared_ptr
  • 不要在作为接口的基类中防止数据成员
  • 用抽象类表示接口
  • 为抽象基类定义一个虚析构函数确保其正确的清理资源
  • 在规模较大的类层次中用override显式的覆盖
  • 用抽象类支持接口继承
  • 用含有数据成员的基类支持实现继承
  • 用普通的多重继承表示特征的组合
  • 用多重继承把实现和接口分离开来
  • 用虚基类表示层次中一部分类公有的内容

第二十二章 运行时类型信息

22.1 引言

  • 一般来说,类是从基类的框架中构造出来的。这种类框架(class lattice)通常被称为类层次。
  • 我们在设计类时,会努力令使用者不必过分操心一个类是如何由其他类组合出来的。特别是,虚调用机制保证了:当我们对一个对象调用函数f()时,对类层次中任何提供了可调用的f()声明的类,以及定义了f()的类,都会调用此函数。
  • 本章将介绍如何在仅有基类提供的接口的情况下获得全部对象信息。

22.2 类层次导航

  • 在运行时使用类型信息通常被称为 运行时类型信息,简写为RTTI(Run-Time Type Information)
  • 从基类到派生类的转换通常称为向下转换(downcast),因为我们画继承树的习惯是从根向下画。类似的,从派生类到基类的转换称为向上转换(upcast)。而从基类到兄弟类的转换,则称为交叉转换(crosscast)

22.2.1 dynamic_cast

  • 运算符dynamic_cast接受两个运算对象: 被 <和>包围的一个类型和被(和)包围的一个指针或引用
  • dynamic_cast要求给定的指针或应用指向一个多态类型,以便进行向下或向上转换。

22.2.3 static_cast和dynamic_cast

  • dynamic_cast可以从一个多态虚基类转换到一个派生类或是一个兄弟类。
  • static_cast则不行,因为它不检查要转换的对象。

22.7 建议

  • 使用虚函数确保无论用什么接口访问对象都执行相同的操作
  • 如果在类层次中导航不可避免,使用dynamic_cast
  • 使用dynamic_cast进行类型安全的显式类层次导航
  • 使用dynamic_cast转换引用类型,当无法转换到所需类时,会被认为是一个错误
  • 使用dynamic_cast转换指针类型,当无法转换到所需类时,会被认为是一个错误
  • 用双重分发或访客模式基于两个动态类型的操作
  • 在构造和重构过程中不要调用虚函数
  • 使用typeid实现扩展的类型信息
  • 使用typeid查询对象的类型,但不要用它查询对象的接口
  • 优选虚函数而不是基于typeid或dynamic_cast的重复的switch语句
感谢老板支持!敬礼(^^ゞ