0%

简介

  • C++17代码整洁之道阅读笔记

第二章 构建安全体系

2.3 单元测试

  • 单元测试是一小段代码,在特定上下文环境中,单元测试能够执行产品的一部分代码。单元测试能够在很短的时间内,展示出你的代码是否达到了预期的运行结果。

  • 单元测试框架

    • C++的单元测试框架有很多种,例如: CppUnit, Boost.Test, CUTE, Google Test等
    • 一般而言,几个单元测试框架的集合成为xUnit,所有遵循所谓的xUnit的基本设计的单元测试的框架,其结构和功能都是从Smalltalk的SUnit集成而来的。

2.5 良好的单元测试原则

  • 单元测试的代码的质量

    • 高质量的要求产品代码,同样高质量的要求单元测试的代码。更进一步的讲,理论上,产品代码和测试代码之间不应该有任何区别
  • 单元测试的命名

    • 如果单元测试失败,开发人员希望立即知道以下信息:
      • 测试单元的名称是什么?谁的单元测试失败了?
      • 单元测试测试了什么?单元测试的环境是怎么样的(测试场景)
      • 预期的单元测试结果是什么?单元测试失败的实例测试结果又是什么
    • 因此,单元测试的命名需要具备直观性和描述性,这是非常重要的
  • 首先,以这样的方式命名单元测试模块(依赖于单元测试框架,称为测试用具或者测试夹具)是很好的做法,这样单元测试代码很容易衍生于单元测试框架。单元测试应该有一个像 Test的名字,很显然,必须用测试对象的名字来替换 占位符

  • 例如,如果被测试的系统(SUT)是Money单位,与该测试单元对应的单元测试夹具,以及所有的单元测试用例都应该命名为MoneyTest

  • 除此之外,单元测试必须有直观的且易理解的名称,如果单元测试的名称或多或少没有意义,那么单元测试的名称不会有太大的帮助。通过下面的建议,可以为单元测试取一个好名字

    • 一般来说,可以在不同场景下使用多种用途的类,一个直观的且易理解的名称应该包含以下三点:
      • 单元测试的前置条件,也就是执行单元测试之前的SUT的状态
      • 被单元测试测试的部分,通常是被测试的过程,函数或者方法(API)的名称
      • 单元测试预期的测试结果
    • 遵循以上三点建议,测试过程或方法的单元测试命名的模板,如下所示
    • 示例
      • void CustomerCacheTest::cacheIsEmpty_addElement_sizeIsOne();
      • void MoneyTest::giveTwoMoneyObjectsWithDifferentBalance_theInequalityComparison_Works();
    • 另一个构建直观的且易理解的单元测试名称的方法,就是在单元测试名称中显示特定的需求。这样的单元测试的名称通常能够反应应用程序域的需求
    • 示例
      • void UserAccountTest::creatingNewAccountWithExistingEmailAddressThrowsException();
      • void BookInventoryTest::aBookThatIsAlreadyBorrowedCanNotBeBorrowedTwice();
    • 几乎所有的单元测试框架都会把失败的单元测试名称输出到标准输出
  • 单元测试的独立性

    • 每个单元测试和其他的单元测试都必须是独立的。如果单元测试之间是以特定的顺序指定的,那么这将是致命的
    • 永远不要编写 一个单元测试的输出是另一个单元测试的输入 的单元测试。当离开一个单元测试的时候,不应该改变测试单元的状态,这是后续单元测试执行的先决条件
  • 单元测试环境的独立初始化

    • 在运行所有单元测试时,每个单元测试都必须是应用程序的一个独立的可运行的实例,每个单元测试都必须完全自行设置和初始化其所需的环境,着同样适用于执行单元测试后的清理工作。
  • 不对getters和setter做单元测试

  • 不对第三方代码做单元测试

    • 我们可以预测第三方代码都有自己的单元测试。在你的项目中,不使用那么没有自己的单元测试和质量可疑的库或框架,这是一种明智的架构选择。
  • 不对外部系统做单元测试。

  • 如何处理数据库的访问

    • 能不使用数据库进行单元测试,就不使用数据库进行单元测试。
    • 只在内存中执行所有的单元测试。
    • 单元测试不要访问数据库,磁盘,网络等外设
    • 数据库测试不是单元测试的内容,它是系统集成和系统测试级别的内容
  • 不要混淆测试代码和产品代码

  • 测试必须快速执行

第三章 原则

  • 我建议学生们把更多的精力放在学习基本思想上,而不是新技术上,因为新技术在他们毕业之前就有可能过时,而基本思想则永远不会过时。

  • 一般来说,不仅是软件开发,把生活中的一切事情变得尽可能简单并不一定都是坏事。

  • 也就是说,下面这些原则我们不应该学一次就忘掉,建议熟练掌握它们。这些原则非常重要,理想情况下,它们会成为每个开发人员的第二天性。

简介

  • 不能只把C++看作是语言要素的一个集合,因为有些要素单独使用是没有意义的。如果我们不只是用C++语言编写代码,而是用它思考“设计”问题,那么必须综合使用这些要素。而且,为了以这种方法理解C++,我们必须了解使用C的问题和一般的编程问题。

  • 我将始终坚持一种观点:读者应当在头脑中建立一个模型,以便逐步理解这种语言,直到炉火纯青的程度。如果读者遇到难题,可以将问题纳入这个模型,推导出答案。

第一章 对象导言

  • 所有的程序设计语言都提供抽象。可以说,人们能解决的问题的复杂性直接与抽象的类型和质量有关。这里的类型指的是:要抽象的东西

  • 程序员必须在机器模型(在”解空间“,即建模该问题的空间中,例如在计算机上)和实际上要解决的问题的模型(在“问题空间”,即问题存在的空间中)之间建立联系。

  • 由于类描述了一组有相同特性(数据元素)和相同行为(功能)的对象,因此类实际上就是数据类型,例如浮点数也有一组特性和行为。

  • 区别在于:程序员定义类是为了与具体问题相适应,而不是被迫使用已存在的数据类型,而设计这些已经存在的数据类型的动机是为了标识机器中的存储单元。程序员可以通过增添专门针对自己需要的新数据类型来扩展程序设计语言。

  • 面向对象程序设计的难题之一,是在问题空间中的元素和解空间的对象之间建立一对一的映射。

  • 把程序员化为为类创建者(创建新数据类型的人)和客户程序员(在应用程序中使用数据类型的类的用户)

    • 客户程序员的目标是去收集一个装满类的工具箱,用于快速应用开发
    • 类创建者的目标是去建造类,这个类只暴露对于客户程序员是必需的东西,其他的都隐藏起来。
  • 为什么呢?因为如果是隐藏的东西,客户程序员就不能使用它,这意味着这个类的创建者可以改变隐藏的部分,而不用担心会影响到其他人

  • 访问控制的第一个理由是为了防止客户程序员插手不应当杰出的部分;第二个理由是允许库设计者去改变这个类的内部工作方式,而不必担心这样做会影响到客户程序员

  • 代码重用是面向对象程序设计语言的最大优点之一

  • 重用一个类最简单的方法就是:直接使用这个类的对象,并且还可以将这个类的对象放到一个新类的里面,称之为“创建一个成员对象”。

  • 可以用任何数量和类型的其他对象组成新类,通过组合得到新类所希望的功能。因为这是由已经存在的类组成新类,所以称为组合(composition),或者更通常地称为聚合(aggregation)

  • 当创建新类时,程序员应当首先考虑组合,因为它更简单和更灵活。

  • 对象的思想本身是一种很方便的工具。我们可以将数据和功能通过概念封装在一起,使得我们能描述合适的问题空间思想,而不是被强制使用底层机器的用语。通过使用class关键字,这些概念被表示为程序设计语言中的基本单元。

  • 继承表示了在基类型和派生类型之间的这种相似性。

  • 一个基类型具有所有由它派生出来的类型所共有的特性和行为。程序员创建一个基类型以描述关于系统中的一些对象的思想核心。由这个基类型,我们可以派生出其他类型来表述实现该核心的不同途径。

  • **早捆绑(early binding)**:编译器会对特定的函数名产生调用,而连接器将这个调用解析为要执行代码的绝对地址。在OOP中,直到程序运行时,编译器才能确定执行代码的地址,所以,当消息被发送给一般对象时,需要采用其他的方案

  • 为了解决这个问题,面向对象语言采用**晚捆绑(late binding)**的思想:当给对象发送消息时,在程序运行时才去确定被调用的代码。编译器保证这个被调用的函数存在,并执行参数和返回值的类型检查(其中不采用这中处理方式的语言称为弱类型(weakly typed)语言),但是它并不知道将执行的确切代码。

  • 我们可以用关键字virtual声明它希望某个函数有晚捆绑的灵活性

  • 在C++中,必须记住添加virtual关键字,因为根据规定,默认情况下成员函数不能动态绑定。virtual函数(虚函数)可用来表示出在相同家族中的类具有不同的行为。这些不同是产生多态行为的原因。

  • 我们把处理派生类型就如同处理其基类型的过程称为向上类型转换(upcasting)

    • cast一次来自铸造领域,up一词来自于继承图的典型排列方式,基类型置于顶层,派生类向下层展开。这样,类型向基类型的转换是沿继承图向上移动,即“向上类型转换”。
  • 从技术角度,OOP的论域就是抽象数据类型,继承和多态性。但是,其他一些问题也是重要的

    • 特别重要的是对象创建和销毁的方法。对象的数据存放在何处?如何控制对象的生命期?不同的程序设计语言有不同的行事之道。C++采取的方法是把效率控制作为最重要的问题,所以它为程序员提供了一个选择。
      • 为了最大化运行速度,通常将对象存放在栈中或静态存储区域中,存储和生命期可以在编写程序时确定。
      • 栈是内存中的一个区域,可以直接由微处理器在程序执行期间存放数据。在栈中的变量有时称为自动变量(automatic variable)或局部变量(scoped variable)
      • 静态存储区域简单说是内存的一个固定块,在程序开始执行以前分配。
      • 使用栈或静态存储区,可以快速分配和释放,有时这是有价值的,然而,这也导致牺牲了灵活性。因为程序员必须在写程序时知道对象的准确数量,生命期和类型。
      • 第二种方法是在称为堆(heap)的区域动态创建对象。这些决定是在程序运行之中作出的。如果需要新的对象,直接使用new关键字让它在堆上生成,当使用结束时,用关键字delete释放。
      • 因为这种存储是在运行时动态管理的,所以在堆上分配存储所需要的时间比在栈上创建存储的时间长的多(在栈上创建存储常常只是一条向下移动栈指针的微处理器指令,另外一条是移回指令)
    • 另一个问题是对象的生命期
      • 如果在栈上或在静态存储上创建一个对象,编译器决定这个对象持续多长时间并能自动销毁它。然而,如果在堆上创建它,编译器则不知道它的生命期。在C++中,程序员必须编程决定何时销毁此对象。然后使用delete关键字执行这个销毁任务。
      • 作为一个替换,运行环境可以提供一个称为**垃圾收集器(garbage collector)**的功能,当一个对象不再被使用时此功能可以自动发现并销毁这个对象。当然,使用垃圾收集器编写程序是非常方便的,但是它需要所有应用软件能忍受垃圾收集器的存在及垃圾收集的系统开销。这并不符合C++语言的设计需要,因此C++没有包括它,尽管存在用于C++的第三方垃圾收集器。
  • 异常处理(exception handling)将错误处理直接与程序设计语言甚至有时是操作系统联系起来。

  • 异常是一个对象,它在出错的地方被抛出,并且被一段用以处理特定类型错误的异常处理代码(exception handler)所接收

  • 方法(method),通常称为方法论(methodology),是一系列的过程和探索,用以降低程序设计问题的复杂性。

  • 考虑采用一个方法之前,理解它试图要解决什么问题是重要的。

  • 如果我们正在考虑的是一个包含丰富细节而且需要许多步骤和文档的方法学,将很难判断什么时候停止,应当牢记我们正在努力寻找的是什么:

    • 什么是对象?(如何将项目分成多个组成部分?)
    • 它们的接口是什么?(需要向每个对象发送什么信息?)
  • C++中引入了异常处理机制来支持复杂的出错处理,从而避免大量的出错处理逻辑干扰程序的代码。

  • **使用断言(assertion)来表示和强化程序中的不变量(invariant)**,是经验丰富的软件工程师的确切标志

第二章 对象的创建与使用

  • 任何一种计算机语言都要从某种人们容易理解的形式(源代码)转化成计算机能执行的形式(机器指令)。通常,翻译其分为两类:解释器(interpreter)和编译器(compiler)

    • 解释器将源代码转换成一些动作(它可由多组机器指令组成)并立即执行这些动作。
    • 编译器直接把源代码转换成汇编语言或机器指令。最终的结果是一个或多个机器代码的文件。
  • 程序可由多个文件构成,一个文件中的函数很可能要访问另一些文件中的函数和数据。编译一个文件时,C或C++编译器必须知道在另一些文件中的函数和数据,特别是它的名字和基本用法。

  • 编译器就是要确保函数和数据被正确地使用。“告知编译器”外部函数和数据的名称及它们的模样,这一过程就是声明(declaration)。一旦声明了一个函数或变量,编译器知道怎么样检查对它们的引用,以确保引用正确。

  • 声明(declaration)和定义(definition)

    • 声明,是向编译器介绍名字–标识符。它告诉编译器:这个函数或这个变量在某处可以找到,它的模样像什么?
    • 定义,为名字分配存储空间。它说:在这里建立变量或者在这里建立函数。无论定义的是函数还是变量,编译器都要为它们在定义点分配存储空间。
  • 在C和C++中,可以在不同的地方声明相同的变量和函数,但是只能有一个定义(有时这称为ODR(one-definition rule, 单一定义规则))

  • 函数声明的语法

    • C/C++的函数声明就是给函数取名,指定函数的参数类型和返回值。
    • 说明:对于带空参数表的函数,C和C++有很大的不同
      • 在C语言中,声明int func();表示:一个可带任意参数(任意数目,任意类型)的函数。这就妨碍了类型检查
      • 在C++语言中它就意味着:不带参数的函数
  • 变量声明的语法

    • 变量声明告知编译器变量的外表特征
    • 函数声明包括函数类型(即返回值类型),函数名,参数列表和一个分号。这些信息使的编译器足以认出它是一个函数声明并可识别出这个函数的外部特征。
    • 由此推断,变量声明应该是类型标识后面跟一个标识符,例如:int a;
    • 可以声明变量a是一个整数,这符合上面的逻辑。但是这就产生了一个矛盾:这段代码有足够的信息让编译器为整数a分配空间,而且编译器也确实给整数a分配了空间。要解决这个矛盾,对于C/C++需要一个关键字来说明:这只是一个声明,它的定义在别的地方。
    • 这个关键字就是extern,它标识变量是在文件以外定义的,或者在文件后面部分才定义的。
    • extern也可用于函数声明。这种声明方式和普通的声明方式一样。因为没有函数体,编译器必定把它作为声明而不是函数定义。extern关键字对函数来说是多余的,可选的。
  • 标准C++ include语句格式

    • 从C继承下来的带有传统.h扩展名的库仍然可用,也可以使用更现代的C++风格使用它们,即**在文件名前面加一个字母c,例如:#include <stdio.h> 变为#include <cstdio>
    • 对所有的标准C头文件都一样。这就提供了一个区分标志,说明所使用的是C还是C++库
    • 新的包含格式和老的效果是不一样的:
      • 使用.h的文件是老的,非模板化的版本
      • 而没有.h的文件是新的模板化版本。
      • 如果在同一个程序中混用这两种形式,会遇到某些问题
  • 名字空间

    • 标准的C++有预防名字冲突的机制:namespace关键字。库或程序中的每一个C++定义集被封装在一个名字空间中,如果其他的定义中有相同的名字,但它们在不同的名字空间,就不会产生冲突。
    • 名字空间是十分方便和有用的工具,但是名字空间的出现意味着在写程序之前,必须知道它们。确切的说,如果仅仅只包含头文件,编译器无法找到任何有关函数和对象的声明。其所代表的含义即:虽然包含了头文件,但是所有的声明都在一个名字空间中,而没有告诉编译其我们想要用这个名字空间中的声明
    • 可以使用一个关键来声明:我要使用这个名字空间中的声明和(或)定义– using
  • Vector

    • 人们经常会把标准C++库的“容器”与“算法”和被称为STL的东西混淆
      • STL(标准模板类库,Standard Template Library)是1994年春天Alex Stepanov在加州San Diego的会议上把他的C++库提交给C++标准委员会时使用的名称。这个名称一直沿用下来
      • 同时,C++标准委员会对STL作了大量的修改,将它整合进标准C++类库。SGI公司不断对STL进行改进。SGI的STL与标准的C++库在许多细节上是不同的。
      • 虽然人们经常产生误解,但实际上C++标准是不“包括”STL的。

第三章C++中的C

  • 标准C和C++有一个特征叫做函数原型(function prototyping)。

    • 用函数原型,在声明和定义一个函数时,必须使用参数类型描述。这种描述就是“原型”
    • 调用函数时,编译器使用原型确保正确传递参数并且正确地处理返回值。如果调用函数时程序员出错了,编译器就会捕获这个错误
  • 用C++编程时,当前C函数库中的所有函数都可以使用。在定义自己的函数之前,应该仔细地看一下函数库,可能有人已经解决了我们的问题,而且进行了更多的思考和调试。

  • do-while语句与while语句的区别在于:即时表达式第一次计值就等于假,前面的语句也会至少执行一次。在一般的while语句中,如果条件第一次为假,语句一次也不会执行。

  • 递归

    • 递归是十分有趣的,有时也是非常有用的编程技巧,凭借递归可以调用我们所在的函数。
    • 当然,如果这是所做的全部,那么会一直调用下去,直到内存用完,所以一定要有一种确定“到达底点”递归调用的方法。
  • 运算符

    • 我们可以把运算符看作是一种特殊的函数(C++的运算符重载正是以这种方式对待运算符)。
    • 一个运算符带一个或更多的参数并产生一个新值。运算符参数和普通的函数调用参数相比在形式上不同,但是作用是一样的。
  • 数据类型

    • 在编写程序中,数据类型(data type)定义使用存储空间(内存)的方式
    • 通过定义数据类型,告诉编译器怎样创建一片特定的存储空间,以及怎样操纵这片存储空间。
    • 数据类型可以是内部的或者是抽象的。
      • 内部数据类型是编译器本来能理解的数据类型,直接与编译器关联。C和C++中的内部数据类型几乎是一样的。
      • 用户定义的数据类型是我们和别的程序员创建的类型,作为一个类。它们一般被称为抽象数据类型。
      • 编译器启动时,知道怎样处理内部数据类型;编译器再通过读包含类声明的头文件认识怎么处理抽象数据类型。
  • 基本内部类型

    • 标准C的内部类型(由C++继承)规范不说明每一个内部类型必须有多少位。规范只规定内部类型必须能存储的最大值和最小值
    • C和C++中有4个基本的内部数据类型
      • char是用于存储字符的,使用最小的8位(一个字节)的存储,尽管它可能占用更大的空间
      • int存储整数值,使用最小两个字节的存储空间
      • floatdouble类型存储浮点数,一般使用IEEE的浮点格式。
  • 说明符(specifier)

    • 说明符用于改变基本内部类型的含义并把它们扩展成一个更大的集合。有四个说明符:long, short, signed, unsigned
    • longshort修改数据类型具有的最大值和最小值
    • signedunsigned修饰符告诉编译器怎样使用整数类型和字符的符号位(浮点数总含有一个符号)unsigned数不保存符号,因此有一个多余的位可用,所以它能存储比signed数大两倍的正数。signed是默认的,只有char才一定要使用signedchar可以默认为signed,也可以不默认为signed。通过规定signed char,可以强制使用符号位。
  • 指针简介

    • 不管什么时候运行一个程序,都是首先把它装入(一般从磁盘装入)计算机内存。因此,程序中所有元素都驻留在内存的某处。
    • 内存一般被布置成一系列连续的内存位置;我们通常把这些位置看作是8位字节,但实际上每一个空间的大小取决于具体机器的结构,一般那称为机器的字长(word size)。每一个空间可按它的地址与其他空间区分。
  • 连接

    • 在一个执行程序中,标识符代表存放变量或被编译过的函数体的存储空间。
    • 连接用连接器所见的方式描述存储空间。连接的方式有两种:内部连接(internal linkage)和外部连接(external linkage)
      • 内部连接意味着只对正被编译的文件创建存储空间。用内部连接,别的文件可以使用相同的标识符或全局变量,连接器不会发现冲突–也就是为每一个标识符创建单独的存储空间。在C和C++中,内部连接是由关键字static指定的
      • 外部连接意味着为所有被编译过的文件创建一片单独的存储空间。一旦创建存储空间,连接器必须解决所有对这片存储空间的引用。全局变量和函数名有外部连接。通过用关键字extern声明,可以从其他文件访问这些变量和函数。函数之外定义的所有变量(在C++中除了const)和函数定义默认为外部连接。可以使用关键字static特地强制它们具有内部连接,也可以在定义时使用关键字extern显式指定标识符具有外部连接。在C中,不必使用extern定义变量或函数,但是在C++中对于const有时必须使用
      • 调用函数时,自动(局部)变量只是临时存储在堆栈中。连接器不知道自动变量,所以这些变量没有连接。

第四章 数据抽象

  • 什么是对象?

    • 把函数放进结构中是从C到C++中的根本改变,这引起我们将结构作为新概念去思考。
    • 在C中,struct是数据的凝聚,它将数据捆绑在一起,使的我们可以将它们看作一个包,但这除了能使编程方便之外,别无其他。对这些结构进行操作的函数可以在别处。
    • 然而将函数也放进这个包内,结构就变成了新的创造物了,它即能描写属性(就像C struct能做的一样),又能描述行为,这就形成了对象的概念。对象是一个独立的捆绑的实体,有自己的记忆和活动。
    • 在C++中,对象就是变量,它的最纯正的定义是“一块存储区”(更明确的说法是:对象必须有唯一的标识,在C++中是一个唯一的地址)。它是一块空间,在这里能够存放数据,而且还隐含着对这些数据进行处理的操作。
  • 抽象数据类型

    • 将数据连同函数捆绑在一起的能力可以用于创建新的数据类型。这常常被称为封装(encapsulation)。
  • 预处理指示#define #ifdef #endif

    • 预处理指示#define可以用来创建编译时标记。
    • 一般有两种选择:可以简单地告诉预处理器这个标记被定义,但不指定特定的值;或者给它一个值(这是典型的定义常数的C方法)
    • 无论哪种情况,预处理器都能测试该标记,检查它是否已经被定义,例如#ifdef FLAG,这将得到一个真值,#ifdef后面的代码将包含在发送给编译器的包中。当预处理器遇到语句#endif或者#endif // FLAG时,包含终止
    • #define的反意是#undefun-define的简写),它将使得使用相同变量的#ifdef语句得到假值。#undef还引起预处理器停止使用宏。#ifdef的反意是ifndef,如果标记还没有定义,它得到真值

第四章小结

  • C++的基本方法,在结构的内部放入函数。结构的这种新类型称为抽象数据类型(abstract data type),用这种结构创建的变量称为这个类型的对象(object)或实例(instance)。调用对象的成员函数称为向这个对象发消息(sending a message)。在面向对象的程序设计中的主要动作就是向对象发消息。

第五章 隐藏实现

  • 访问控制通常是指实现细节的隐藏(implementation hiding)。将函数包含到一个结构内(常称为封装)来产生一种带数据和操作的数据类型,由访问控制在该数据类型之内确定边界,这样做的原因有两个
    • 首先是决定那些客户程序员可以用,那些客户程序员不能用。我们可以建立结构内部的机制,而不必担心客户程序员会把内部的数据机制当作它们可使用的接口的一部分来访问
    • 将具体实现与接口分离开来。如果该结构被用在一系列程序中,而客户程序员只能对public的接口发送消息,这样就可以改变所有声明为private的成员而不必去修改客户程序员的代码。

C++简介

  • C++融合了三种不同的编程方式:
    • C语言代表的过程性语言
    • C++在C语言基础上添加的类代表的面向对象语言
    • C++模板支持的泛型编程

C++简史

  • 汇编语言,依赖于计算机的内部机器语言。

  • 它是低级语言(low-level),即直接操作硬件,例如直接访问CPU寄存器和内存单元。因此,汇编语言针对于特定的计算机处理器,要将汇编程序移植到另一种计算机上,必须使用不同的汇编语言重新编写程序

  • 高级语言(high-level),致力于解决问题,而不针对特定的硬件。

  • 一种被称为编译器的特殊程序将高级语言翻译成特定计算机的内部语言。这样,就可以通过对每个平台使用不同的编译器来在不同的平台上使用同一个高级语言程序了

  • 一般来说,计算机语言要处理两个概念–数据和算法

    • 数据,是程序使用和处理的信息
    • 算法,是程序使用的方法
  • 结构化编程,将分支(决定接下来应执行哪个指令)限制为一小组行为良好的结构。

  • C语言的词汇表中就包含了这些结构:for循环,while循环,do while循环,if else语句

  • 结构化编程技术反映了过程性编程的思想,根据执行的操作来构思一个程序

  • 面向对象编程(OOP),与强调算法的过程性编程不同的是,OOP强调的是数据。它不像过程性编程那样,试图使问题满足语言的过程性方法,而是试图让语言来满足问题的要求。其理念是设计与问题的本质特性相对应的数据格式。

  • 在C++中,类是一种规范,它描述了这种新型数据格式,对象是根据这种规范构造的特定数据结构。

  • OOP程序设计方法首先设计类,它们准确地表示了程序要处理的东西。类定义描述了对每个类可执行的操作,然后便可以设计一个使用这些类的对象的程序。

  • 从低级组织(如类)到高级组织(如程序)的处理过程,叫做自下向上(bottom-up)的编程

  • OOP编程并不仅仅是将数据和方法合并为类定义。

    • 它还有助于创建可重用的代码,这将减少大量的工作。
    • 信息隐藏可以保护数据
    • 多态能够为运算符和函数创建多个定义,通过编程上下文来确定使用哪个定义
    • 继承能够使用旧类派生出新类
  • OOP引入了很多新的理念,使用的编程方法不同于过程性编程。它不是将重点放在任务上,而是放在表示概念上。

  • 泛型编程(generic programming)是C++支持的另一种编程模式。它与OOP的目标相同,即使重用代码和抽象通用概念的技术更简单。

  • 不过OOP强调的是编程的数据方便,而泛型编程强调的是独立于特定数据类型。它们的侧重点不同。

  • OOP是一个管理大型项目的工具,而泛型编程提供了执行常见任务(例如对数据排序或合并链表)的工具。

  • 术语泛型(generic)指的是,创建独立于类型的代码。

    • C++的数据表示有多种类型–整数,小数,字符,字符串,用户定义的,由多种类型组成的符合结构。
    • 例如,要对不同类型的数据进行排序,通常必需为每种类型创建一个排序函数。
    • 泛型编程需要对语言进行扩展,以便可以只编写一个泛型(即不是特定类型的)函数,并将其用于各种实际类型。
    • C++模板提供了完成这种任务的机制。

第二章 开始学习C++

  • 语句,是要执行的操作。

  • 为理解源代码,编译器需要直到一条语句何时结束,另一条语句何时开始。有些语句使用语句分隔符。

  • C++与C一样,使用终止符(terminator),而不是分隔符。

  • 终止符是一个分号,它是语句的结束标记,是语句的组成部分,而不是语句之间的标记

  • 结论:在C++中,不能省略分号

  • 通常,C++函数可被其他函数激活或调用

  • 函数头描述了函数与调用它的函数之间的接口。

  • 位于函数名前面的部分叫做函数返回类型,它描述的是从函数返回给调用它的函数的信息

  • 函数名后括号中的部分叫做形参列表(argument list)或参数列表(parameter list)。它描述的是从调用函数传递给被调用的函数的信息。

  • C++注释以双斜杠(//)打头,到行尾结束。注释可以位于单独的一行上,也可以和代码位于同一行

  • C-风格注释,包括在符号/**/之间。由于C-风格注释以*/结束,而不是到行尾结束,因此可以跨越多行。事实上,C99标准也在C语言中添加了//注释

  • 源代码中的标记和空白

    • 一行代码中不可分隔的元素叫做标记(token)。
    • 通常,必需用空格,制表符或回车将两个标记分开。空格,制表符和回车统称为空白(white space)。
  • C++源代码风格

    • 每条语句占一行
    • 每个函数都有一个开始花括号和一个结束花括号,这两个花括号各占一行
    • 函数中的语句都相对于花括号进行缩进
    • 与函数名称相关的圆括号周围没有空白。
  • C++程序是一组函数,而每个函数又是一组语句

  • 计算机是一种精确的,有条理的机器。要将信息项存储在计算机中,必须指出信息的存储位置和所需的内存空间。

  • 在C++中,完成这种任务的一种相对简便的方法,是使用声明语句来指出存储类型并提供位置标签。

  • 程序中的声明语句叫做定义声明(defining declaration)语句,简称为定义(definition)。这意味着它将导致编译器为变量分配内存空间。在较为复杂的情况下,还可能有引用声明(reference declaration)

  • 总结

    1. C++程序由一个或多个被称为函数的模块组成。程序从main()函数开始执行,因此该函数必不可少。函数由函数头和函数体组成。函数头指出函数的返回值的类型和函数期望通过参数传递给它的信息的类型。函数体由一系列位于花括号{}中的C++语句组成
    2. 有多种类型的C++语句,包括:
      • 声明语句,定义函数中使用的变量的名称和类型
      • 赋值语句,使用赋值运算符=给变量赋值
      • 消息语句,将消息发送给对象,激发某种行为
      • 函数调用,执行函数。被调用的函数执行完毕后,程序返回到函数调用语句后面的语句
      • 函数原型,声明函数的返回类型,函数接受的参数数量和类型
      • 返回语句,将一个值从被调用的函数那里返回到调用函数中。

第三章 处理数据

  • 计算机内存的基本单元是位(bit)。
  • 可以将位看作电子开关,可以开,也可以关。关表示值0,开表示值1。
  • 8为的内存块可以设置出256中不同的组合,因为每一位都可以有两种设置,所以8位的总组合数为256。
  • 字节(byte),通常指的是8位的内存单元。从这个意义上来说,字节指的就是描述计算机内存量的度量单位,1KB等于1024字节,1MB等于1024KB。

第四章 复合类型

指针与C++基本原理

  • 面向对象编程与传统的过程性编程的区别在于,OOP强调的是在运行阶段(而不是编译阶段)进行决策
    • 运行阶段,指的是程序正在运行时
    • 编译阶段,指的是编译器将程序组合起来时。
  • 运行阶段决策,就好比度假时,选择参观那些景点取决于天气和当时的心情;而编译阶段决策更像不管在什么条件下,都坚持预先设定的日程安排。
  • 运行阶段决策提供了灵活性,可以根据当时的情况进行调整

指针小结

  • 声明指针
  • 给指针赋值。应将内存地址赋给指针。可以对变量名应用&运算符,来获得被命名的内存的地址,new运算符返回未命名的内存的地址
  • 对指针解除引用。
    • 对指针解除引用意味着获得指针指向的值。对指针应用解除引用或间接值运算符(*)来解除引用。
    • 另一种对指针解除引用的方法是使用数组表示法。例如,pn[0]*pn是一样的。一定不要对未被初始化为适当地址的指针解除引用。
  • 区分指针和指针所指向的值
  • 数组名。在多数情况下,C++将数组名视为数组的第一个元素的地址。一种例外情况是,将sizeof运算符用于数组名时,此时将返回整个数组的长度(单位为字节)
  • 指针算术
  • 数组的动态联编和静态联编
    • 使用数组声明来创建数组时,将采用静态联编,即数组的长度在编译时设置
    • 使用new[]运算符创建数组时,将采用动态联编(动态数组),即将在运行时为数组分配空间,其长度也将在运行时设置。使用完这种数组后,应该使用delete[]释放其占用的内存
  • 数组表示法和指针表示法
    • 使用方括号数组表示法等同于对指针解除引用
    • 数组名和指针变量都是如此,因此对于指针和数组名,即可以使用指针表示法,也可以使用数组表示法

自动存储,静态存储和动态存储

  • 自动存储

    • 在函数内部定义的常规变量使用自动存储空间,被称为自动变量(automatic variable),这意味着它们在所属的函数被调用时自动产生,在该函数结束时小王
    • 实际上,自动变量是一个局部变量,其作用域为包含它的代码块。代码块是被包含在花括号中的一段代码。
    • 自动变量通常存储在栈中。这意味着执行代码块时,其中的变量将依次加入到栈中,而在离开代码块时,将按相反的顺序释放这些变量,这被称为后进先出(LIFO)。因此,在程序执行过程中,栈将不断地增大和缩小。
  • 静态存储

    • 静态存储是整个程序执行期间都存在的存储方式。
    • 使变量成为静态的方式有两种:一种是在函数外面定义它,另一种是在声明变量时使用关键字static
  • 动态存储

    • new和delete运算符提供了一种比自动变量和静态变量更灵活的方法。
    • 它们管理了一个内存池,这在C++中被称为自由存储空间(free store)或堆(heap)。
    • 该内存池同用于静态变量和自动变量的内存是分开的。new和delete能够在一个函数中分配内存,而在另一个函数中释放它。因此数据的生命周期不完全受程序或函数的生存时间控制。
  • 栈,堆和内存泄漏

    • 如果使用new运算符在自由存储空间(或堆)上创建变量后,没有调用delete,则即使包含指针的内存由于作用域规则和对象生命周期的原因而被释放,在自由存储空间上动态分配的变量或结构也将继续存在。
    • 实际上,将会无法访问自由存储空间中的结构,因为指向这些内存的指针无效。这将导致内存泄漏。被泄漏的内存将在程序的整个生命周期内都不可使用;这些内存被分配出去,但无法收回。
    • 极端情况(不过不常见)是,内存泄漏可能会非常严重,以致于应用程序可用的内存被耗尽,出现内存耗尽错误,导致程序崩溃。另外,这种泄漏还会给一些操作系统或在相同的内存空间中运行的应用程序带来负面影响,导致它们崩溃。

第八章

  • 内联函数是C++为提供程序运行速度所做的一项改进。常规函数和内联函数之间的主要区别不在于编写方式,而在于C++编译器如何将它们组合到程序中。

  • 编译过程的最终产品是可执行程序–由一组机器语言指令组成。运行程序时,操作系统将这些指令载入到计算机内存中,因此每条指令都有特定的内存地址。计算机随后将逐步执行这些指令。

  • 引用变量

    • 引用是已定义的变量的别名(另一个名称)
    • 引用变量的主要用途是用作函数的形参。通过将引用变量用作参数,函数将使用原始数据,而不是其副本。这样除指针之外,引用也为函数处理大型结构提供了一种非常方便的途径,同时对于设计类来说,引用也是必不可少的
    • int rats; int & rodents = rats; // make rodents an alias for rate
    • 其中,&不是地址运算符,而是类型标识符的一部分。就像声明中的char*指的是指向char的指针一样,int&指的是指向int的引用。
    • 引用经常被用作函数参数,使得函数中的变量名称为调用程序中的变量的别名,这种传递参数的方法称为按引用传递。按引用传递允许被调用的函数能够访问调用函数中的变量

实例化和具体化

  • 为进一步了解模板,必需理解术语:实例化和具体化
  • 谨记:在代码中包含函数模板本身并不会生成函数定义,它只是一个用于生成函数定义的方案。编译器使用模板为特定类型生成函数定义时,得到的是模板实例(instantiation)
    • 例如,函数调用Swap(i, j)导致编译器生成Swap()的一个实例,该实例使用int类型。模板并非函数定义,但是使用int的模板实例是函数定义。**这种实例化方式被称为隐式实例化(implicit instantiation),因为编译器之所以知道需要进行定义,是由于程序调用Swap()函数时提供了int参数。
    • 最初,编译器只能通过隐式实例化,来使用模板生成函数定义,但是现在C++还允许显式实例化(explicit instantiation)。这意味着可以直接命令编译器创建特定的实例,例如Swap<int>()。其语法是,声明所需要的种类–使用<>符号指示类型,并在声明前加上关键字templatetemplate void Swap<int>(int, int); // explicit instantiation

小结

  • 引用变量是一种伪装指针,它允许为变量创建别名(另一个名称)。引用变量主要被用作处理结构和类对象的函数的参数。

  • 通常,被声明为特定类型引用的标识符只能指向这种类型的数据;然而,如果一个类是从另一个类派生出来的,则基类引用可以指向派生类对象

  • 函数的特征标是它的参数列表。程序员可以定义两个同名函数,只要其特征标不同。这被称为函数多态或函数重载。

  • 通常,通过重载函数来为不同的数据类型提供相同的服务

  • 函数模板自动完成重载函数的过程。只需使用泛型和具体算法来定义函数,编译器将为程序中使用的特定参数类型生成正确的函数定义

第九章 内存模型和名称空间

头文件管理

  • 在同一个文件中只能将同一个头文件包含一次。记住这个规则很容易,但很可能在不知情的情况下将头文件包含多次。
    • 例如,可能使用包含了另一个头文件的头文件。有一种标准的C/C++技术可以避免多次包含同一个文件。
  • 它是基于预处理编译指令#ifndef(即if not defined)

自动变量和栈

  • 了解典型的C++编译器如何实现自动变量,有助于更深入地了解自动变量

  • 由于自动变量的数目随函数的开始和结束而增减,因此程序必须在运行时对自动变量进行管理。

  • 常用的方法是留出一段内存,并将其视为栈,以管理变量的增减。之所以被称为栈,是由于新数据被象征性地放在原有数据的上面(也就是说,在相邻的内存单元中,而不是在同一个内存单元中),当程序使用完后,将其从栈中删除。栈的默认长度取决于实现,但编译器通常提供改变栈长度的选项。

  • 程序使用两个指针来跟踪栈,一个指针指向栈底–栈的开始位置,另一个指针指向栈顶–下一个可用内存单元。当函数被调用时,其自动变量将被加入到栈中,栈顶指针指向变量后面的下一个可用的内存单元。函数结束时,栈顶指针被重置为函数被调用前的值,从而释放新变量使用的内存。

  • 栈是LIFO(后进先出)的,即最后加入到栈中的变量首先被弹出。这种设计简化了参数传递。函数调用将其参数的值放在栈顶,然后重新设置栈顶指针。被调用的函数根据其形参描述来确定每个参数的地址。

寄存器变量

  • 关键字register最初是由C语言引入的,它建议编译器使用CPU寄存器来存储自动变量,这样的目的是–提高访问变量的速度。

说明符和限定符

  • 有些被称为存储说明符(storage class specifier)或cv-限定符(cv-qualifier)的C++关键字提供了其他有关存储的信息

  • 存储说明符

    1. auto(在C++11中不再是说明符)
      • 在C++11之前,可以在声明中使用关键字auto指出变量为自动变量
      • 在C++11中,auto用于自动类型推断。
    2. register
      • 用于在声明中指示寄存器存储,
      • 在C++11中,它只是显式地指出变量是自动的
    3. static
      • 它被用在作用域为整个文件的声明中时,表示内部链接性
      • 被用于局部声明中,表示局部变量的存储持续性为静态的
    4. extern
      • 它表明是引用声明,即声明引用在其他地方定义的变量
    5. thread_local(C++11新增加的,可与staticextern结合使用)
      • 它指出变量的持续性与其所属线程的持续性相同
      • thread_local变量之于线程,犹如常规静态变量之于整个程序
    6. mutable
      • 它的含义将根据const来解释
      • 可以用它来指出,即时结构(或类)变量为const,其某个成员也可以被修改
  • cv-限定符(cv表示const volatile)

    1. const
      • 它是最常用的cv-限定符,它表明–内存被初始化后,程序便不能再对它进行修改
    2. volatile
      • 它表明,即时程序代码没有对内存单元进行修改,其值也可能发生变化;该关键字的作用是为了改善编译器的优化能力
      • 例如,假设编译器发现,程序在几条语句中两次使用了某个变量的值,则编译器可能不是让程序查找这个值两次,而是将这个值缓存到寄存器中。这种优化假设变量的值在这两次使用之间不会变化。如果不将变量声明为volatile,则编译器将进行这种优化;将变量声明为volatile,相当于告诉编译器,不要进行这种优化。

小结

  • C++鼓励程序员在开发程序时使用多个文件。一种有效的组织策略是,使用头文件来定义用户类型,为操纵给用户类型的函数提供函数原型;并将函数定义放在一个独立的源代码文件中。头文件和源代码文件一起定义和实现了用户定义的类型及其使用方式。最后,将main()和其他使用这些函数的函数放在第三个文件中

第十章 对象和类

  • 过程性编程方法 – 首先考虑要遵循的步骤,然后考虑如何表示这些数据(并不需要程序一直运行,用户可能希望能够将数据存储在一个文件中,然后从这个文件中读取数据)

  • OOP方法 – 首先从用户的角度考虑对象,描述对象所需的数据以及描述用户与数据交互所需的操作。完成对接口的描述后,需要确定如何实现接口和数据存储。最后,使用新的设计方案创建出程序。

  • 指定基本类型完成了三项工作

    • 决定数据对象需要的内存数量
    • 决定如何解释内存中的位(long和float在内存中占用的位数相同,但将它们转换为数值的方法不同)
    • 决定可使用数据对象执行的操作或方法
  • 类,是一种将抽象转换为用户定义类型的C++工具,它将数据表示和操纵数据的方法组合成一个整洁的包

  • 类规范由两个部分组成

    • 类声明:以数据成员的方式描述数据部分,以成员函数(被称为方法)的方式描述公有接口
    • 类方法定义:描述如何实现类成员函数
    • 简单地说,类声明提供了类的蓝图,而方法定义则提供了细节

什么是接口?

  • 接口,是一个共享框架,供两个系统(例如在计算机和打印机之间或者用户和计算机程序之间)交互时使用

  • 对于类,我们说公共接口。在这里,公共(public)是使用类的程序,交互系统由类对象组成,而接口由编写类的人提供的方法组成。

  • 接口,让程序员能够编写与类对象交互的代码,从而让程序能够使用类对象。

  • 为开发一个类并编写一个使用它的程序,需要完成多个步骤。这里将开发过程分成多个阶段,而不是一次性完成。

  • 通常,C++程序员将接口(类定义)放在头文件中,并将实现(类方法的代码)放在源代码文件中。

访问控制

  • 使用类对象的程序都可以直接访问公有部分,但只能通过公有成员函数来访问对象的私有成员。

  • 因此,公有成员函数是程序和对象的私有成员之间的桥梁,提供了对象和程序之间的接口。防止程序直接访问数据被称为数据隐藏

  • 类设计尽可能将公有接口与实现细节分开。

    • 公有接口表示设计的抽象组件。将实现细节放在一起并将它们与抽象分开被称为封装。
    • 数据隐藏(将数据放在类的私有部分中)是一种封装,将实现的细节隐藏在私有部分中,也是一种封装
    • 封装的另一个例子是,将类函数定义和类声明放在不同的文件中
  • 类和结构

    • 类描述看上去很像是包含成员函数以及public和private可见性标签的结构声明。
    • 实际上,C++对结构进行了扩展,使之具有与类相同的特性。它们之间唯一的区别是,结构的默认访问类型是public,而类为private。C++程序员通常使用类来实现类描述,而把结构限制为只表示纯粹的数据对象(常被称为普通老式数据结构)

实现类成员函数

  • 创建类描述的第二个部分:为那些由类声明中的原型表示的成员函数提供代码。

  • 成员函数定义与常规函数定义非常相似,它们有函数头和函数体,也可以有返回类型和参数。但是它们还有两个特殊的特征:

    • 定义成员函数时,使用作用域解析运算符(::)来表示函数所属的类
    • 类方法可以访问类的private组件
  • 成员函数的函数头使用作用域运算符解析(::)来指出函数所属的类。因此,作用域解析运算符确定了方法定义对应的类的身份。

内联方法

  • 定义位于类声明中的函数都将自动成为内联函数

  • 内联函数的特殊规则要求在每个使用它们的文件中都对其进行定义。确保内联定义对多文件程序中的所有文件都可用的,最简便的方法是:将内联定义放在定义类的头文件中(有些开发系统包含智能链接程序,允许将内联定义放在一个独立的实现文件)

方法使用哪个对象

  • 所创建的每个新对象都有自己的存储空间,用于存储其内部变量和类成员;但同一个类的所有对象共享同一组类方法,即每种方法只有一个副本。

    • 例如,假设kate和joe都是Stock对象,则kate.shares将占据一个内存块,而joe.shares占用另一个内存块,但kate.show()joe.show()都调用同一个方法,也就是说,它们将执行同一个代码块,只是将这些代码用于不同的数据。
  • 在OOP中,调用成员函数被成为发送消息,因此将同样的消息发送给两个不同的对象将调用同一个方法,但是该方法被用于两个不同的对象。

类作用域

  • 在类中定义的名称(如类数据成员名和类成员函数名)的作用域都为整个类,作用域为整个类的名称只在该类中是已知的,在类外是不可知的。

  • 另外,类作用域意味着不能从外部直接访问类的成员,公有成员函数也是如此。也就是说,要调用公有成员函数,必需通过对象

  • 同样,在定义成员函数时,必需使用作用域解析运算符

  • 总之,在类声明或成员函数定义中,可以使用未修饰的成员名称。

  • 构造函数名称在被调用时,才能被识别,因为它的名称与类名相同

  • 其他情况下,使用类成员名时,必需根据上下文使用直接成员运算符(.),间接成员运算符(->)或作用域解析运算符(::)

小结

  • 面向对象编程强调的是程序如何表示数据。使用OOP方法解决编程问题的第一步是根据它与程序之间的接口来描述数据,从而指定如何使用数据。然后,设计一个类来实现该接口。一般来说,私有数据成员存储信息,公有成员函数(又称方法)提供访问数据的唯一途径。类将数据和方法组合成一个单元,其私有性实现数据隐藏

  • 类是用户定义的类型,对象是类的实例。这意味着对象是这种类型的变量。例如由new按类描述分配的内存

第十一章 使用类

  • 学习C++的难点之一是需要记住大量的东西,但在拥有丰富的实践经验之前,根本不可能全部记住这些东西。

  • 掌握知识的好的方法是,在自己开发的C++程序中使用其中的新特性。对这些新特性有了充分的认知后就可以添加其他C++特性

  • 正如C++创始人Bjarne Stroustrup在一次C++专业程序员大会上所建议的:轻松地使用这种语言。不要觉得必须使用所有的特性,不要在第一次学习时就试图使用所有的特性

第十二章 类和动态内存分配

指针和对象小结

  • 使用常规表示法来声明指向对象的指针 – String* gla;

  • 可以将指针初始化为指向已有的对象 – String* first = &saying[0];

  • 可以使用new来初始化指针,这将创建一个新的对象 – String* favorite = new String(sayings[choice])

  • 对类使用new将调用相应的类构造函数来初始化新创建的对象

  • 可以使用->运算符通过指针访问类方法 – shortest->length()

  • 可以对对象指针应用解除引用运算符(*)来获得对象 – first = &saying[i];

第十三章 类继承

  • 希望同一个方法在派生类和基类中的行为是不同的。换句话来说,方法的行为应该取决于调用方法的对象。这种较复杂的行为称为多态–具有多种形态,即同一个方法的行为随上下文而异。

  • 有两种重要的机制可用于实现多态公有继承:

    • 在派生类中重新定义基类的方法
    • 使用虚方法
  • 注意

    • 如果要在派生类中重新定义基类的方法,通常应将基类方法声明为虚的。这样,程序将根据对象类型而不是引用或指针的类型来选择方法版本。
    • 为基类声明一个虚析构函数也是一种惯例

静态联编和动态联编

  • 程序调用函数时,将使用哪一个可执行代码块? 编译器负责回答这个问题

  • 将源代码中的函数调用解释为执行特定的函数代码块被称为函数名联编(binding)

  • 在C语言中,这非常简单,因为每个函数名都对应一个不同的函数

  • 在C++中,由于函数重载的远古,这项任务更复杂。编译器必须查看函数参数及函数名才能确定使用哪一个函数。

  • C/++编译器可以在编译过程完成这种联编。在编译过程中进行联编被称为静态联编(static binding),又称为早期联编(early binding)

  • 然而,虚函数使这项工作变得更困难。因为虚函数使得 – 使用哪一个函数是不能在编译时确定的。因为编译器不知道用户将选择那种类型的对象,所以,编译器必须生成能够在程序运行时选择正确的虚函数的代码,这被称为动态联编(dynamic binding),又称为晚期联编(late binding)

  • 将派生类引用或指针转转为基类引用或指针 被称为向上强制转换(upcasting),这使公有继承不需要进行显式类型转换。

  • 相反的过程 – 将基类指针或引用转换为派生类指针或引用 被称为向下强制转换(downcasting)

  • 编译器对非虚方法使用静态联编

  • 为什么有两种类型的联编以及为什么默认为静态联编? – 效率和概念模型

  • 效率

    • 为了使程序能够在运行阶段进行决策,必须采取一些方法来跟踪基类指针或引用指向的对象类型,这增加了额外的处理开销
    • C++的指导原则之一是 – 不要为不使用的特性付出代价(内存或处理时间)。仅当程序设计确实需要虚函数时,才适用它们
  • 虚函数的工作原理

    1. 通常,编译器处理虚函数的方法是:给每个对象添加一个隐藏成员。隐藏成员中保存了一个指向函数地址数组的指针。这种数组称为虚函数表(virtual function table, vtbl)。
    2. 虚函数表中存储了为类对象进行声明的虚函数的地址。
    • 例如,基类对象包含了一个指针,该指针指向基类中所有虚函数的地址表。派生类对象将包含一个指向独立地址的指针。如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地址;如果派生类没有重新定义虚函数,该vtbl将保存函数原始版本的地址。如果派生类定义了新的虚函数,则该函数的地址也将被添加到vtbl中
    • 调用虚函数时,程序将查看存储在对象中的vtbl地址,然后转向相应的函数地址表。如果使用类声明中定义的第一个虚函数,则程序将使用数组中的第一个函数地址,并执行具有该地址的函数。如果使用类声明中的第三个虚函数,程序将使用地址为数组中第三个元素的函数。
  • 总之,使用虚函数时,在内存和执行速度方面有一定的成本,包括

    • 每个对象都将增大,增大量为存储地址的空间
    • 对于每个类,编译器都要创建一个虚函数地址表(数组)
    • 对于每个函数调用,都需要执行一项额外的操作,即在表中查找地址
  • 有关虚函数注意事项

    • 在基类方法的声明中使用关键字virtual可以使该方法在基类以及所有的派生类(包括从派生类派生出来的类)中是虚的
    • 如果使用指向对象的引用或指针来调用虚方法,程序将使用为对象类型定义的方法,而不使用为引用或指针类型定义的方法。这称为动态联编或晚期联编。这种行为非常重要。因为这样基类指针或引用可以指向派生类对象
    • 如果定义的类将被用作基类,则应将那些要在派生类中重新定义的类方法声明为虚的。
  • 当基类和派生类都采用动态内存分配时,派生类的析构函数,复制构造函数,赋值运算符都必须使用相应的基类方法来处理基类元素。

  • 这种要求是要通过三种不同的方式来满足的。对于析构函数,这是自动完成的;对于构造函数,这是通过在初始化成员列表中调用基类的复制构造函数来完成的;如果不这样做,将自动调用基类的默认构造函数。对于赋值运算符,这是通过使用作用域解析运算符显式地调用基类的赋值运算符来完成的。

小结

  • 继承,通过使用已有的类(基类)定义新的类(派生类),使得能够根据需要修改编程代码。

C++中的代码重用

  • C++的一个主要目标是促进代码重用。公有继承是实现这种目标的机制之一,但并不是唯一的机制。

  • 方法之一是使用这样的类成员–本身是另一个类的对象。这种方法称为包含(containment), 组合(composition)或层次化(layering)

  • 另一种方法是使用私有或保护继承。通常,包含,私有继承和保护继承用于实现has-a关系,即新的类将包含另一个类的对象。

  • C++和约束

    • C++包含让程序员能够限制程序结构的特性–使用explicit防止单参数构造函数的隐式转换,使用const限制方法修改数据,等等。
    • 这样做的根本原因是:**在编译阶段出现错误优于在运行阶段出现错误
  • 使用包含还是私有继承?

    • 由于即可以使用包含,也可以使用私有继承来建立has-a关系,那么应该使用那种方式?
    • 通常,应该使用包含来建立has-a关系;
    • 如果新类需要访问原有类的保护成员,或者需要重新定义虚函数,则应该使用私有继承

类模板

  • template <class Type>
  • 关键字template告诉编译器,将要定义一个模板。尖括号中的内容相当于函数的参数列表。可以把关键字class看作是变量的类型名,该变量接受类型作为其值,把Type看作是该变量的名称。
  • 这里使用class并不意味着Type必须是一个类;而只是表明Type是一个通用的类型说明符,在使用模板时,将使用实际的类型替换它。较新的C++实现允许在这种情况下使用不太容易混淆的关键字typename代替class
    • template <typename Type> // newer choice
  • 可以使用自己的泛型名代替Type,其命名规则与其他标识符相同。**当前流行的选项包括TType
  • 当模板被调用时,Type将被具体的类型值(例如int, string)取代。在模板定义中,可以使用泛型名来表示要存储在栈中的类型。

小结

  • C++提供了几种重用代码的手段

  • 公有继承能够建立is-a关系,这样派生类可以重用基类的代码

  • 私有继承和保护继承也使得能够重用基类的代码,但是建立的是has-a关系

    • 使用私有继承时,基类的公有成员和保护成员将称为派生类的私有成员
    • 使用保护继承时,基类的公有成员和保护成员将成为派生类的保护成员
  • 无论使用哪一种继承,基类的公有接口都将成为派生类的内部接口,这有时候被称为继承实现,但并不继承接口,因为派生类对象不能显式地使用基类的接口。因此,不能将派生对象看作是一种基类对象。由于这个原因,在不进行显式类型转换的情况下,基类指针或引用将不能指向派生类对象。

  • 还可以通过开发包含对象成员的类来重用类代码,这种方法被称为包含,层次化或组合。

  • 它建立的是has-a关系。与私有继承和保护继承相比,包含更容易实现和使用,所以通常优先采用这种方式。

  • 然而,私有继承和保护继承比包含有一些不同的功能。例如,继承允许派生类访问基类的保护成员;还允许派生类重新定义从基类那里继承的徐函数。因为包含不是继承,所以通过包含来重用类代码时,不能使用这些功能

  • 另一个方面,如果需要使用某个类的几个对象,则用包含更加合适。

  • 所有这些机制的目的都是为了让程序员能够重用经过测试的代码,而不用手工复制它们,这样可以简化编程工作,提供程序的可靠性。

第十五章 友元,异常和其他

  • 在C++中,可以将类声明放在另一个类中。在另一个类中声明的类称为嵌套类(nested class),它通过提供新的类型类作用域来避免名称混乱。
  • 包含的类的成员函数可以创建和使用被嵌套类的对象;而仅当声明位于公有部分,才能在包含类的外面使用嵌套类,而且必须使用作用域解析运算符。

异常

  • 如果其中一个参数是另一个参数的负值,则调用abort()函数。Abort()的函数原型位于头文件cstdlib中,其典型实现是向标准错误流(即cerr使用的错误流)发送消息abnormal program termination(程序异常终止),然后终止程序。它还返回一个随实现而异的值,告诉操作系统(如果程序是由另一个程序调用的,则告诉父进程)处理失败。
  • abort()是否刷新文件缓冲区(用于存储读写到文件中的数据的内存区域)取决于实现。

返回错误码

  • 一种比异常终止更灵活的方法是,使用函数的返回值来指出问题

异常机制

  • C++异常是对程序运行过程中发生的异常情况的一种响应。

  • 异常提供了将控制权从程序的一个部分传递到另一个部分的途径。

  • 对异常的处理有3个组成部分:

    • 引发异常;
    • 使用处理程序捕获异常
    • 使用try块
  • 程序在出现问题时将引发异常。**throw语句实际上是跳转,即命令程序跳到另一条语句**。

  • throw关键字表示引发异常,紧随其后的值(例如字符串或对象)指出了异常的特征。

  • 程序使用异常处理程序(exception handler)来捕获异常,异常处理程序位于要处理问题的程序中。

  • catch关键字表示捕获异常。处理程序以关键字catch开头,随后是位于括号中的类型声明,它指出了异常处理程序要响应的异常类型;然后是一个用花括号括起来的代码块,指出要采取的措施。

  • catch关键字和异常类型用作标签,指出当异常被引发时,程序应该跳到这个位置执行。异常处理程序也被称为catch

  • try块标识其中特定的异常可能被激活的代码块,它后面跟一个或多个catch块。try块是由关键字try指示的,关键字try的后面是一个由花括号括起来的代码块,表明需要注意这些代码引发的异常。

  • 通常,引发异常的函数将传递一个对象。这样做的重要优点之一是,可以使用不同的异常类型来区分不同的函数在不同的情况下引发的异常。

  • 另外,对象可以携带信息,程序员可以根据这些信息来确定引发异常的原因。同时,catch块可以根据这些信息来决定采取什么样的措施

栈解退

  • C++通常是如何处理函数调用和返回的。

  • C++通常通过将信息放在栈中来处理函数调用。具体地说,程序将调用函数的指令的地址(返回地址)放到栈中。当被调用的函数执行完毕后,程序将使用该地址来确定从哪里开始继续执行。另外,函数调用将函数参数放到栈中。在栈中,这些函数参数都被视为自动变量。如果被调用的函数创建了新的自动变量,则这些变量也将被添加到栈中。如果被调用的函数调用了另一个函数,则后者的信息将被添加到栈中,依次类推。

  • 当函数结束时,程序流程将跳到该函数被调用时存储的地址处,同时栈顶的元素被释放。因此,函数通常都返回到调用它的函数,依次类推,同时每个函数都在结束时释放其自动变量。如果自动变量是类对象,则类的析构函数将被调用。

  • 现在假设函数由于出现异常(而不是由于返回)而终止,则程序也将释放栈中的内存,但不会在释放栈的第一个返回地址后停止,而是继续释放栈,直到找到一个位于try块中的返回地址。随后,控制权将转到块尾的异常处理程序,而不是函数调用后面的第一条语句。这个过程被称为栈解退

第十六章 string类和标准模板库

  • STL提供了一组表示容器,迭代器,函数对象和算法的模板。

  • 容器是一个与数组类似的单元,可以存储若干个值。 STL容器是同质的,即存储的值的类型相同

  • 算法是完成特定任务(如对数组进行排序或在链表中查找特定值)的处方

  • 迭代器能够用来遍历容器的对象,与能够遍历数组的指针类似,是广义指针;

  • 函数对象是类似于函数的对象,可以是类对象或函数指针(包含函数名,因为函数名被用作指针)。

  • STL使得能够构造各种容器(包括数组,队列和链表)和执行各种操作(包括搜索,排序和随机排列)

泛型编程

  • STL是一种泛型编程(generic programming)。面向对象编程关注的编程的数据方面,而泛型编程关注的是算法。它们之间的共同点是抽象和创建可重用代码,但它们的理念绝然不同。

  • 泛型编程旨在编写独立于数据类型的代码。在C++中,完成通用程序的工具是模板。当然,模板使得能够按泛型定义函数或类,而STL通过通用算法更进了一步。模板让这一切成为可能,但必须对元素进行仔细地设计。为了解模板和设计是如何协同工作的,来看一看需要迭代器的原因。

  • 理解迭代器是理解STL的关键所在。模板使得算法独立于存储的数据类型,而迭代器使得算法独立于使用的容器类型。因此,它们都是STL通用方法的重要组成部分。

  • 泛型编程旨在使用同一个find函数来处理数组,链表或任何其他容器类型。即函数不仅独立于容器中存储的数据类型,而且独立于容器本身的俄数据结构。模板提供了存储在容器中的数据类型的通用表示,因此还需要遍历容器中的值的通用表示,迭代器正是这样的通用表示。

  • 每个容器类(vector list deque等)定义了相应的迭代器类型。对于其中的某个类,迭代器可能是指针;而对于另一个类,则可能是对象。不管实现方式如何,迭代器都将提供所需的操作。

  • 其次,每个容器类都有一个超尾标记,当迭代器递增到超越容器的最后一个值后,这个值将被赋给迭代器。每个容器类都有begin()end()方法,它们分别返回一个指向容器的第一个元素和超尾位置的迭代器。每个容器都有++操作,让迭代器从指向第一个元素逐步指向超尾位置,从而遍历容器中的每一个元素。

  • 使用容器类时,无需知道其迭代器是如何实现的,也无需知道超尾是如何实现的,而只需要知道它有迭代器,其begin()返回一个指向第一个元素的迭代器,end()返回一个指向超尾位置的迭代器即可。

  • 总结一下STL方法。首先是处理容器的算法,应尽可能通用的术语来表达算法,使之独立于数据类型和容器类型。为使通用算法能够适用于具体情况,应定义能够满足算法需求的迭代器,并把要求加到容器设计上。即基于算法的要求,设计基本迭代器的特征和容器特征。

如何理解迭代??

  • 迭代,是一个重复的过程,每次重复都是基于上一次的结果而继续的,单纯的重复并不是迭代,例如,A + B =》 E ,生成的过程就是迭代,克隆就不是迭代

  • 迭代器,指的是迭代取值的工具。而涉及到把多个值循环取出来的类型有:列表,字符串,元组,字典,集合,打开的文件对象

  • 实现一个简单的迭代取值(基于索引)的方式,只适用于有索引的数据类型:列表,字符串,字典

  • 为了解决基于索引迭代取值的局限性,就必须提供一种能够不依赖索引的取值方式,这就是迭代

  • 在一个迭代器取值干净的情况下,再对其取值,取不到,必须再调用一次迭代器才能取值


  • STL定义了五种迭代器,并根据所需要的迭代器类型对算法进行了描述。这五种迭代器分别是输入迭代器,输出迭代器,正向迭代器,双向迭代器和随机访问迭代器。
    • 输入迭代器 – 被程序用来读取容器中的信息,是单向迭代器,可以递增,但不能倒退
    • 输出迭代器 – 用于将信息从程序传输到容器的迭代器,因此程序的输出就是容器的输入。
    • 正向迭代器 – 每次沿容器向前移动一个元素,并总是按照相同的顺序遍历一系列值
    • 双向迭代器 – 具有正向迭代器的所有特性,同时支持两种(前缀和后缀)递减运算符
    • 随机访问迭代器 – 具有双向迭代器的所有特性,同时添加了支持随机访问的操作和用于对元素进行排序的关系运算符

概念,改进和模型

  • STL有若干个用C++语言无法表达的特性,例如迭代器种类。因此,虽然可以设计具有正向迭代器特征的类,但不能让编译器将算法限制为只使用这个类。

  • 原因在于,正向迭代器是一系列要求,而不是类型。所设计的迭代器类可以满足这种要求,常规指针也能满足这种要求。

  • STL算法可以使用任何满足其要求的迭代器实现。STL文献使用术语概念(concept)来描述一系列的要求。因此,存在输入迭代器概念,正向迭代器概念等。

  • 概念,可以具有类似继承的关系。例如,双向迭代器继承了正向迭代器的功能。然而,不能将C++继承机制用于迭代器。例如,可以将正向迭代器实现为一个类,而将双向迭代器实现为一个常规指针。

  • 因此,对C++而言,这种双向迭代器是一种内置类型,不能从类派生而来。然而,从概念上看,它确实能够继承。有些STL文献使用术语**改进(refinement)**来表示这种概念上的继承,因此,双向迭代器是对正向迭代器概念的一种改进。

  • **概念的具体实现被称为模型(model)**。因此,指向int的常规指针是一个随机访问迭代器模型,也是一个正向迭代器模型,因为它满足该概念的所有要求。


关联容器

  • 关联容器(associative container)是对容器概念的另一个改进。关联容器将值与键关联在一起,并使用键来查找值。

  • 关联容器的优点在于,它提供了对元素的快速访问。与序列相似,关联容器也允许插入新元素,但不能指定元素的插入位置。原因是关联容器通常有用于确定数据放置位置的算法,以便能够快速检索信息。

  • 关联容器通常是使用某种树实现的。

  • STL提供了四种关联容器:set, multiset, map, multimap。前两种是在头文件set中定义的,后两种是在头文件map中定义的


函数对象

  • 很多STL算法都是用函数对象 – 也叫函数符(functor)。函数符是可以以函数方式与()结合使用的任意对象。这包括函数名,指向函数的指针和重载了()运算符的类对象(即定义了函数operator()的类)

总结

  • C++提供了一组功能强大的库,这些库提供了很多常见编程问题的解决方案以及简化其他问题的工具。string类为将字符串作为对象来处理提供了一种方便的方法。string类提供了自动内存管理功能以及众多处理字符串的方法和函数

  • STL是一个容器类模板,迭代器类模板,函数对象模板和算法函数模板的集合,它们的设计是一致的,都是基于泛型编程原则的。算法通过使用模板,从而独立于所存储的对象的类型:通过使用迭代器接口,从而独立于容器的类型。迭代器是广义指针

  • STL使用术语概念来描述一组要求

  • 有些算法被表示为容器类方法,但大量算法都被表示为通用的,非成员函数,这是通过将迭代器作为容器和算法之间的接口得以实现的

  • 容器和算法都是由其提供或需要的迭代器类型表征的。

第十七章 输入, 输出和文件

  • 多数计算机语言的输入和输出是以语言本身为基础实现的。但是C和C++都没有将输入和输出建立在语言中。这两种语言的关键字包括for和if,但不包括与I/O有关的内容。C语言最初把I/O留给了编译器实现人员。这样做的一个原因是为了让实现人员能够自由的设计I/O函数,使之最适合于目标计算机的硬件要求。实际上,多数实现人员都把I/O建立在最初为UNIX环境开发的库函数的基础之上。ANSI C 正式承认这个I/O软件包时,将其称为标准输入/输出包,并将其作为标准C库不可或缺的组成部分。C++也认可这个软件包,因此如果熟悉stdio.h文件中声明的C函数系列,则可以在C++程序中使用它们,较新的实现使用头文件cstdio来支持这些函数。

  • C++依赖于C++的I/O解决方案,而不是C语言的I/O解决方案,前者是在头文件iostream中定义一组类

流和缓冲区

  • C++程序把输入和输出看作字节流。输入时,程序从输入流中抽取字节;输出时,程序将字节插入到输出流中。对于面向文本的程序,每个字节代表一个字符,更通俗地说,字节可以构成字符或数值数据的二进制表示。

  • 输入流中的字节可能来自键盘,也可能来自存储设备(例如硬盘)或其他程序。同样,输出流中的字节可以流向屏幕,打印机,存储设备或者其他程序。流充当了程序和流源或流目标之间的桥梁

  • 这使得C++程序可以以相同的方式对待来自键盘的输入和来自文件的输入。C++程序只是检查字节流,而不需要知道字节来自何方。同理,通过使用流,C++程序处理输出的方式将独立于其去向。因此管理输入包含两部:

    • 将流和输入去向的程序关联起来
    • 将流和文件连接起来
  • 换句话说,输入流需要两个连接,每端各一个。文件端部连接提供了流的来源,程序端连接将流的流出部分转储到程序中(文件端连接可以是文件,也可以是设备)。同样,对输出的管理包括将输出流连接到程序以及将输出目标与流关联起来。

  • 通常,通过使用缓冲区可以更高效地处理输入和输出。

  • 缓冲区,是用作中介的内存块,它是将信息从设备传输到程序或从程序传输给设备的临时存储工具

  • 缓冲区帮忙匹配两种不同的信息传输速率。

  • 输出时,程序首先填满缓冲区,然后把整块数据传输给硬盘,并清空缓冲区,以备下一批输出使用,这被称为刷新缓冲区(flushing the buffer)

文件输入和输出

  • 大多数计算机程序都使用了文件。字处理程序创建文档文件;数据库程序创建和搜索信息文件;编译器读取源代码文件并生成可执行文件。
  • 文件本身是存储在某种设备(磁盘,光盘,软盘或硬盘)上的一系列字节。通常,操作系统管理文件,跟踪它们的位置,大小,创建时间等。

小结

  • 流,是进出程序的字节流。
  • 缓冲区是内存中的临时存储区域,是程序与文件或其他I/O设备之间的桥梁。
  • 信息在缓冲区和文件之间传输时,将使用设备(例如磁盘驱动器)处理效率最高的尺寸以大块数据的方式进行传输。
  • 信息在缓冲区和程序之间传输时,是逐字节传输的。这种方式对于程序中的处理操作更为方便。
  • C++通过将一个被缓冲流同程序及其输入源相连接来处理输入。同样,C++也通过将一个被缓冲流和程序及其输出目标相连来处理输出。
  • iostreamfstream文件构成了I/O类库,该类库定义了大量用于管理流的类。

探讨C++新标准

  • 如果仔细阅读了本书,则应很好地掌握了C++的规则,然而,这仅仅是学习这种语言的开始,接下来需要学习如何高效地使用该语言,这样的路更长。更好的情况是,工作或学习环境能够接触到优秀的C++代码和程序员。

  • 另外,了解C++后,便可以阅读一些介绍高级主体和面向对象编程的书记,附录H列出了一些这样的资源。

  • OOP有助于开发大型项目,并提高其可靠性。OOP方法的基本活动之一是发明能够表示正在模拟的情况(被称为问题域(problem domain))的类。

  • 由于实际问题通常很复杂,因此找到适当的类富有挑战性。创建复杂的系统时,从空白开始通常不可行,最好采用逐步迭代的方式。为此,该领域的实践者开发了多种技术和策略。具体地说,重要的是在分析和设计阶段完成尽可能多的迭代工作,而不要不断地修改实际代码

  • 除了加深对C++的总体理解外,还可能需要学习特定的类库

附录H

  • C++常见问题解答,第二版 – Cline, Marshall, Greg Lomow and Mike Girou. C++FAQ, Second Edition

  • C++标准库教程和参考手册 – Josuttis, Nicolai M. The C++ Standard Library:A Tutorial and Reference

  • Meyers, Scott. Effective C++:55 Specific Ways to Improve Your Programs and Designs, Third Edition.

    • 本书针对的是了解C++的程序员,提供了55条规定和指南。其中一些是技术性的,例如解释何时应该定义复制构造函数和赋值运算符;其他一些更为通用,例如对is-a has-a关系的讨论
  • Stroustrup,Bjarne. The C++ Programming Language. Third Edition.

  • http://webstore.ansi.org

  • www.iso.org

  • http://www.parashift.com/C++-faq-lite

简介

  • 抽象,是有选择的忽略。
  • 编程依赖于一种选择,选择什么何时忽略
  • 编程就是通过建立抽象来葫芦俄那些我们此刻并不重视的因素
  • 本书坚持以两个思想为核心:实用和抽象

抽象

  • 在处理大问题的时候,这样的工具总是能够帮助将问题分解成独立的子问题,并能确保它们相互独立,也就是说当处理问题的某个部分的时候,完全不必担心其他部分

  • 有些抽象不是语言的一部分

  • 文件的概念

    • 事实上每种操作系统都以某种方式使文件能为用户所用。在大多数情况下,文件根本不是物理存在的。
    • 文件只是组织长期存储的数据的一种方式,并且由程序和数据结构的集合提供支持来实现这个抽象。
  • 通常,我们不可能为特定的工具挑选合适的问题

面向对象编程(OOP)

  • 面向对象编程,指使用继承和动态绑定的编程方式

  • 继承,是一种抽象,它允许程序员在某些时候忽略相似对象间的差异,又在其它时候利用这些差异。

  • 采用这种编译时检查的方式,是因为C++能够为动态绑定的函数调用快速生成代码

  • 只有在程序通过指向基类对象的指针或者基类对象的引用调用虚函数时,才会发生运行时的多态现象。

  • 任何虚函数只有在继承的情况下才有用

  • 对象的创建和复制不是运行时多态的。所以容器:无论是类似于数组或者结构体的内建容器还是用户自定义容器类:只能获得编译时类型已知的元素值

  • 如果有一系列类之间存在继承关系,当需要创建,复制和存储对象,而这些对象的确切类型只有到运行时才能够知道时,这种编译时的检查会带来一些麻烦

  • 通常,解决这个问题的方法是增加一个间接层。C++采用了一种更自然的方法。就是:定义一个类来提供并且隐藏这个间接层,这种类,通常叫做句柄(handle)类

  • 句柄类采用最简单的形式,把一个单一类型的对象与一个与之有某种特定继承关系的任意类型的对象捆绑起来。

  • 句柄类的一个常见用户就是通过避免不必要的复制来优化内存管理

  • 容器通常只能包含一种类型的对象,所以很难在容器中存储对象本身

  • 存储指向对象的指针,虽然允许通过继承来处理类型不同的问题,但是也增加了内存分配的额外负担

  • 定义一个行为和Vehicle对象相似,而有潜在地表示了所有继承自Vehicle类的对象的东西。我们把这中类的对象叫做代理(surrogate)

handle classe

  • 需要一种方法,让我们在避免某些缺点(如缺乏安全性)的同时能够获取指针的某些优点,尤其是能够在保持多态性的前提下避免复制对象的代价。
  • C++的解决方法就是定义一个适当的类,由于这些类的对象通常被绑定到它们所控制的类的对象上,所以这些类常被称为handle类(handle classe)。因为这些handle的行为类似指针,所以有时也被称为智能指针smart pointer

引用计数型句柄

  • 之所以要使用句柄,原因之一就是为了避免不必要的对象复制。也就是说,得允许多个句柄绑定到单个对象上
  • 写时复制,copy on write。其有点是只有在绝对必要时才进行复制,从而避免了不必要的复制。在涉及句柄的类库中,这一技术经常用到

面向对象程序范例

  • 通常认为,面向对象编程有三个要素:数据抽象,继承以及动态绑定
  • 解决方法的实质是要对希望模拟的下层系统中的对象进行建模。当我们分析出表达式树是有节点和边所构成,便可以设计数据抽象来对树进行建模。
  • 继承让我们抓住了各种节点类型之间的相似之处,而动态绑定帮助我们为各种类型节点定义操作,让编译器来负责安排在运行时能够调用正确的函数。
  • 这样,数据抽象加上动态绑定可以让我们集中精力考虑每个类型的行为和实现,而不必关心与其他对象的交互。

设计

  • 在解决问题的时候,有一点要始终牢记,不仅要看到眼前的问题,还要看到长远的变化。
  • 在实际开发中,灵活性通常是有意义的,因为它使我们面对需求的变更不至于一切推翻重来。至于应当为这种灵活性付出多大代价,当然有一个工程上的权衡问题,只能根据对环境的理解来作出回答。
  • 我们必须清楚,在选择一个设计方案之前,必须首先把问题及其背景搞清楚

虚函数

  • 虚函数是C++的基本组成部分,也是面向对象编程所必需的。然而,虚函数并不一定总是适用的
  • 关于虚函数为什么不总是适用,大致有三个原因:
    1. 虚函数有时候会带来很大的消耗
    2. 虚函数不总是提供所需的行为
    3. 有时候我们写一个类时,可能不想考虑派生的问题
  • 另一个方面,我们还知道了一种必须使用虚函数的情况。当需要删除一个表面上指向基类对象,实际上却是指向派生类对象的指针,就是需要虚析构函数。

模板

  • 从某种意义上来说,模板只不过是语法宏的一种受限形式

  • 通常将容器称为模板,而容器内的对象的类型就是模板参数

  • 因此,对于任意类型T,可以想象容器可以是List<T>或者Set<T>

  • 模板是泛型编程的基础,泛型编程即以一种独立于任何特定类型的方式编写代码

  • 模板是创建泛型类或函数的蓝图或公式。库容器,比如迭代器和算法,都是泛型编程的例子,它们都使用了模板的概念。

  • 每个容器都有一个单一的定义,比如向量,我们可以定义许多不同类型的向量,比如vector<int> 或者vector <string>

  • 可以使用模板来定义函数和类

  • 模板函数定义的一般形式如下:

    1
    2
    3
    template <typename type> ret-type func-name(parameter list){
    // 函数主体
    }
  • 在这里,type是函数所使用的数据类型的占位符的名称,这个名称可以在函数定义中使用

  • 类模板,正如定义函数模板一样,也可以定义类模板,泛型类声明的一般形式如下:

    1
    2
    3
    template <class type> class class-name{

    }
  • 在这里,type是占位符类型名称,可以在类被实例化的时候进行指定。可以使用一个逗号分隔的列表来定义多个泛型数据类型

  • C++最基本的设计原则就是用类来表示概念。指针把数组的标识和内部空间结合在一起

迭代器

  • 通常情况下,每个容器类都有一个或者多个相关的迭代器类型。迭代器能使我们在不暴露容器内部结构的情况下访问容器的元素。

模板和泛型算法

  • 1994年7月,在安大略基其纳召开的C++标准会议上,委员会投票通过了一项由Alex Stepanov提出的建议,即将他和他的同事们在Hewlett-Packard实验室开发的一系列泛型算法作为一部分收录到标准C++标准库中。这些被包含到库中的类和算法合起来称为标准模板库(Standard Template Library, STL
  • 所谓泛型算法,就是这样的算法:对于所操作的数据结构的细节信息,只加入最低限度的了解。当然,理想的情况应该是根本不需要这样的信息,但是现实却不是这样,作为一种折中,STL根据数据结构能够支持的有效操作,将这些数据结构进行分类,然后对于每个算法,它会指出该算法所需要的数据结构类别
  • 被分类的不是算法,也不是数据结构,而是用来访问数据结构的类型。这些类型的对象叫做迭代器。
  • 迭代器共有五种:输入迭代器,输出迭代器,前向迭代器,双向迭代器和随即存取迭代器。
  • 概念继承将这些种类关联起来,之所以称之为“概念的”,是因为这些种类本身都是概念,而不是类型或者对象。

函数对象

  • 除了迭代器和配接器外,STL还提供了一种称为函数对象(function object)的概念。
  • 简单地说,函数对象提供了一种方法,将要调用的函数与准备传递给这个函数的隐式参数捆绑起来,这就允许我们使用相当简单的语法来建立复杂的表达式
  • 函数对象表示了一种操作。通过组合函数对象,我们可以得到复杂的操作。

  • 抽象数据类型,ADT

  • 数据抽象的目的就是控制复杂度

  • C++中的一个更为重要的思想(尽管这个思想不是C++特有的)是:用户自定义类型可以很容易地当作内建类型使用。通过定义新类型,用户可以为了他们自己的目的来定制语言

抽象数据类型

  • 如果我们准备在接口和实现之间实现完全隔离,就会希望语言支持数据抽象。
  • C++语言中将接口与实现分隔开的最基本的方法之一就是采用构造函数和析构函数。正是这两个函数允许类设计者能够说:这个类的对象使用对象本身内容之外的信息。
    • 构造函数本身提供了生成给定类对象的方法
    • 析构函数则提供了与构造函数相反的行为

命名空间

  • 命名空间解决了一种在C中十分突出而在C++中愈加严重的问题:如何防止不同的程序库设计者为各自组建采用相同的名字
  • 本质上,命名空间允许库设计者对会被库放到全局作用域的所有名称指定一个包装器(wrapper)

技术

  • C++的一个基本思想就是:通过类定义可以明确指明当这个类的对象被构造,销毁,复制和赋值时应该发生什么事情。这意味着设计的当的类可以为理解程序的动态行为提供一个强有力的工具,这一点往往比人们所认识的更重要

  • C++程序经常要为一整组对象分配内存,随后将他们同时释放。解决这个问题的方法之一是定义一个包含这样的集合的类。事实证明,为了让一个集合包含不相关的类的对象,一个好方法是使用多重继承。

总结

  • 一种常见对C++的批评是说该语言太复杂。我认为只有在孤立地看待C++时,这种观点才成立。设计任何一门语言,也可以说是任何软件,都是有特定的背景。
  • C++面向的是特定的用户群。这个用户群要应付各种复杂的问题,写出要运行相当长时间的解决方案。这些解决方案必须满足任意的性能需求,要工作在不同的硬件和操作平台上,还要和许多已经存在的系统共存。
  • 学习和使用C++的建议:
    • 做理解的事情;理解要做的事情
    • 逐步加深扩展理解
    • 做练习时要把握分寸,过犹不及
    • 依据操作思考。从C到C++最大的观念性变化就是要停止考虑程序的结构,而开始考虑程序数据的行为。
    • 早些考虑测试
    • 思考。所有的建议都只是建议。是否对你有用是由你决定的。你可以依照我的建议做任何事情,尽量确切的遵循它,或者拼命反其道而行之,或者忽略它,或者对此嗤之以鼻。不管怎么对待,你都要清楚为什么要这样做。不管你理不理解都要对结果负责。

采访

  • 是否应该更加重视标准库教育,而不是语言细节的教育?

  • 当然是库优先于语言细节。两个原因:

    • 首先,学生们可以不必费力包装低层次的语言细节,从而更容易建立整体语言的全局观念,了解到其真实威力。不过根据我们的经验,学生们首先掌握如何使用程序库之后,就会很容易理解类的概念,学会如何构造类的技术。如果首先去学习语言细节,那么就很难理解类的概念及其功能。这种理解上的缺陷,使他们很难设计和构造自己的类。
    • 更重要的一点是,首先学习程序库,能够是学生培养起良好的习惯,就是复用库代码,而不是凡事自己动手。首先学习语言细节的学生,最后的编程风格往往是C类型的,而不是C++风格。他们不会充分地运用库,而自己的程序带有严重的C主义倾向:指针满天飞,整个程序都是低层次的。结果是,在很多情况下,你为C++的复杂性付出了高昂代价,却没有从中获得任何好处。
  • 一个问题产生良好的设计方案的途径,就是使用一种允许你进行各种设计的工具。这样一来,你就可以选择最适合该问题的设计方案。如果你选择了这样的工具,那么你就必须负责选择合适的设计方案。

  • 为什么认为“基于对象”和“基于模板”的抽象机制优先于面向对象抽象机制?

  • 所谓面向对象编程,就是使用继承和动态绑定机制编程。如果你知道有一个很好的程序使用了继承和动态绑定,你能作出怎样的推断?在我们看来,这意味着该程序中有两个或两个以上的类型,至少有一个共同的操作,也至少有一个不同的操作。否则就不需要继承机制。此外,程序中必然有一个场景,需要在运行时从这些类型中挑选出一个,否则就不需要动态绑定机制

  • 某些面向对象编程语言,如Python,其所有类型都是动态的,那么技术书籍的作者就不会面对这样的问题。例如,C++中的容器类大多数用模板写成,因其可以容纳毫无共同之处的对象,所以要求元素类型必须是某个共同基类的派生类毫无道理。然而,在Python中,容器类中本来就可以放置任何对象,所以类似模板那样的类型机制就不必要了。

  • 所以,我认为你所看到的问题,其实是因为很难找到又小又好的面向对象程序做范例,才会产生的。而且,对于其他语言必须依赖动态类型才能解决的问题,C++能够使用模板来高效地解决。

  • 如果我说我只能记住你的一句话,那一定是:用类来表示概念。假设再记住一句话,应该是什么?

  • 避免重复。如果发现自己在程序的两个不同部分里做了相同的事情,试着把这两个部分合并到一个子过程中。如果发现两个类的行为相近,试着把这两个类的相似部分统一到基类或模板中

  • 我们都希望成为更好的C++程序员,请给我们三那个你认为最重要的建议。

    • 避免使用指针
    • 提倡使用程序库
    • 使用类来表示概念

简介

引言

  • C++ 是一个用户群体相当大的语言。从 C++98 的出现到 C++11 的正式定稿经历了长达十年多之久的积累。

  • C++14/17 则是作为对 C++11 的重要补充和优化,C++20 则将这门语言领进了现代化的大门,所有这些新标准中扩充的特性,给 C++ 这门语言注入了新的活力。

  • 那些还在坚持使用传统 C++ (本书把 C++98 及其之前的 C++ 特性均称之为传统 C++)而未接触过现代 C++ 的 C++ 程序员在见到诸如 Lambda 表达式这类全新特性时,甚至会流露出『学的不是同一门语言』的惊叹之情

  • 现代 C++ (本书中均指 C++11/14/17/20) 为传统 C++ 注入的大量特性使得整个 C++ 变得更加像一门现代化的语言。现代 C++ 不仅仅增强了 C++ 语言自身的可用性,auto 关键字语义的修改使得我们更加有信心来操控极度复杂的模板类型。同时还对语言运行期进行了大量的强化,Lambda 表达式的出现让 C++ 具有了『匿名函数』的『闭包』特性,而这一特性几乎在现代的编程语言(诸如 Python/Swift/… )中已经司空见惯,右值引用的出现解决了 C++ 长期以来被人诟病的临时对象效率问题等等

  • C++17 则是近三年依赖 C++ 社区一致推进的方向,也指出了 现代C++ 编程的一个重要发展方向。尽管它的出现并不如 C++11 的分量之重,但它包含了大量小而美的语言与特性(例如结构化绑定),这些特性的出现再一次修正了我们在 C++ 中的编程范式。

  • 现代 C++ 还为自身的标准库增加了非常多的工具和方法,诸如在语言自身标准的层面上制定了 std::thread,从而支持了并发编程,在不同平台上不再依赖于系统底层的 API,实现了语言层面的跨平台支持;std::regex 提供了完整的正则表达式支持等等。C++98 已经被实践证明了是一种非常成功的『范型』,而现代 C++ 的出现,则进一步推动这种范型,让 C++ 成为系统程序设计和库开发更好的语言。Concept 提供了对模板参数编译期的检查,进一步增强了语言整体的可用性。

第一章 迈向现代C++

1.1 被弃用的特性

  • 注意:弃用并非彻底不能用,只是用于暗示程序员这些特性将从未来的标准中消失,应该尽量避免使用。但是,已弃用的特性依然是标准库的一部分,并且出于兼容性的考虑,大部分特性其实会『永久』保留。
  • 在学习现代 C++ 之前,我们先了解一下从 C++11 开始,被弃用的主要特性:
    • 不再允许字符串字面值常量赋值给一个 char *。如果需要用字符串字面值常量赋值和初始化一个 char *,应该使用 const char * 或者 auto。
      • char *str = "hello world!"; // 将出现弃用警告
    • C++98 异常说明、 unexpected_handler、set_unexpected() 等相关特性被弃用,应该使用 noexcept。
    • auto_ptr 被弃用,应使用 unique_ptr。
    • register 关键字被弃用,可以使用但不再具备任何实际含义。
    • bool 类型的 ++ 操作被弃用
    • 如果一个类有析构函数,为其生成拷贝构造函数和拷贝赋值运算符的特性被弃用了。
    • C 语言风格的类型转换被弃用(即在变量前使用 (convert_type)),应该使用 static_cast、reinterpret_cast、const_cast 来进行类型转换。
    • 特别地,在最新的 C++17 标准中弃用了一些可以使用的 C 标准库,例如 <ccomplex><cstdalign><cstdbool><ctgmath>
    • ……等等
    • 还有一些其他诸如参数绑定(C++11 提供了 std::bind 和 std::function)、export 等特性也均被弃用

1.2 与C的兼容性

  • 出于一些不可抗力、历史原因,我们不得不在 C++ 中使用一些 C 语言代码(甚至古老的 C 语言代码),例如 Linux 系统调用。在现代 C++ 出现之前,大部分人当谈及『C 与 C++ 的区别是什么』时,普遍除了回答面向对象的类特性、泛型编程的模板特性外,就没有其他的看法了,甚至直接回答『差不多』,也是大有人在
  • 从现在开始,你的脑子里应该树立『C++ 不是 C 的一个超集』这个观念(而且从一开始就不是,后面的进一步阅读的参考文献中给出了 C++98 和 C99 之间的区别)。
  • 在编写 C++ 时,也应该尽可能的避免使用诸如 void* 之类的程序风格。而在不得不使用 C 时,应该注意使用 extern "C" 这种特性,将 C 语言的代码与 C++代码进行分离编译,再统一链接这种做法

第二章 语言可用性的强化

  • 当我们声明、定义一个变量或者常量,对代码进行流程控制、面向对象的功能、模板编程等这些都是运行时之前,可能发生在编写代码或编译器编译代码时的行为。
  • 为此,我们通常谈及语言可用性,是指那些发生在运行时之前的语言行为。

2.1 常量

nullptr

  • nullptr 出现的目的是为了替代 NULL。在某种意义上来说,传统 C++ 会把 NULL、0 视为同一种东西,这取决于编译器如何定义 NULL,有些编译器会将 NULL 定义为 ((void*)0),有些则会直接将其定义为 0。
  • C++ 不允许直接将 void * 隐式转换到其他类型。但如果编译器尝试把 NULL 定义为 ((void*)0),那么在下面这句代码中:char *ch = NULL;
  • 没有了 void * 隐式转换的 C++ 只好将 NULL 定义为 0。而这依然会产生新的问题,将 NULL 定义成 0 将导致 C++ 中重载特性发生混乱。考虑下面这两个 foo 函数:
    • void foo(char *);
    • void foo(int);
  • 那么 foo(NULL); 这个语句将会去调用 foo(int),从而导致代码违反直觉。
  • 为了解决这个问题,C++11 引入了 nullptr 关键字,专门用来区分空指针、0。而 nullptr 的类型为 nullptr_t,能够隐式的转换为任何指针或成员指针的类型,也能和他们进行相等或者不等的比较

constexpr

  • C++ 本身已经具备了常量表达式的概念,比如 1+2, 3*4 这种表达式总是会产生相同的结果并且没有任何副作用。如果编译器能够在编译时就把这些表达式直接优化并植入到程序运行时,将能增加程序的性能。一个非常明显的例子就是在数组的定义阶段:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    #include <iostream>
    #define LEN 10

    int len_foo() {
    int i = 2;
    return i;
    }
    constexpr int len_foo_constexpr() {
    return 5;
    }

    constexpr int fibonacci(const int n) {
    return n == 1 || n == 2 ? 1 : fibonacci(n-1)+fibonacci(n-2);
    }

    int main() {
    char arr_1[10]; // 合法
    char arr_2[LEN]; // 合法

    int len = 10;
    // char arr_3[len]; // 非法

    const int len_2 = len + 1;
    constexpr int len_2_constexpr = 1 + 2 + 3;
    // char arr_4[len_2]; // 非法
    char arr_4[len_2_constexpr]; // 合法

    // char arr_5[len_foo()+5]; // 非法
    char arr_6[len_foo_constexpr() + 1]; // 合法

    std::cout << fibonacci(10) << std::endl;
    // 1, 1, 2, 3, 5, 8, 13, 21, 34, 55
    std::cout << fibonacci(10) << std::endl;
    return 0;
    }
  • 上面的例子中,char arr_4[len_2] 可能比较令人困惑,因为 len_2 已经被定义为了常量。为什么 char arr_4[len_2] 仍然是非法的呢?这是因为 C++ 标准中数组的长度必须是一个常量表达式,而对于 len_2 而言,这是一个 const 常数,而不是一个常量表达式,因此(即便这种行为在大部分编译器中都支持,但是)它是一个非法的行为,我们需要使用接下来即将介绍的 C++11 引入的 constexpr 特性来解决这个问题;而对于 arr_5 来说,C++98 之前的编译器无法得知 len_foo() 在运行期实际上是返回一个常数,这也就导致了非法的产生

  • 注意,现在大部分编译器其实都带有自身编译优化,很多非法行为在编译器优化的加持下会变得合法,若需重现编译报错的现象需要使用老版本的编译器。

  • C++11 提供了 constexpr 让用户显式的声明函数或对象构造函数在编译期会成为常量表达式,这个关键字明确的告诉编译器应该去验证 len_foo 在编译期就应该是一个常量表达式。

  • 此外,constexpr 修饰的函数可以使用递归:

    1
    2
    3
    constexpr int fibonacci(const int n) {
    return n == 1 || n == 2 ? 1 : fibonacci(n-1)+fibonacci(n-2);
    }
  • 从 C++14 开始,constexpr 函数可以在内部使用局部变量、循环和分支等简单语句,例如下面的代码在 C++11 的标准下是不能够通过编译的:

    1
    2
    3
    4
    5
    constexpr int fibonacci(const int n) {
    if(n == 1) return 1;
    if(n == 2) return 1;
    return fibonacci(n-1) + fibonacci(n-2);
    }

2.2 变量及其初始化

if/switch 变量声明强化

  • 在传统 C++ 中,变量的声明虽然能够位于任何位置,甚至于 for 语句内能够声明一个临时变量 int,但始终没有办法在 if 和 switch 语句中声明一个临时的变量。例如:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    #include <iostream>
    #include <vector>
    #include <algorithm>

    int main() {
    std::vector<int> vec = {1, 2, 3, 4};

    // 在 c++17 之前
    const std::vector<int>::iterator itr = std::find(vec.begin(), vec.end(), 2);
    if (itr != vec.end()) {
    *itr = 3;
    }

    // 需要重新定义一个新的变量
    const std::vector<int>::iterator itr2 = std::find(vec.begin(), vec.end(), 3);
    if (itr2 != vec.end()) {
    *itr2 = 4;
    }

    // 将输出 1, 4, 3, 4
    for (std::vector<int>::iterator element = vec.begin(); element != vec.end();
    ++element)
    std::cout << *element << std::endl;
    }
  • 在上面的代码中,我们可以看到 itr 这一变量是定义在整个 main() 的作用域内的,这导致当我们需要再次遍历整个 std::vector 时,需要重新命名另一个变量。C++17 消除了这一限制,使得我们可以在 if(或 switch)中完成这一操作:
    1
    2
    3
    4
    5
    // 将临时变量放到 if 语句内
    if (const std::vector<int>::iterator itr = std::find(vec.begin(), vec.end(), 3);
    itr != vec.end()) {
    *itr = 4;
    }

初始化列表

  • 初始化是一个非常重要的语言特性,最常见的就是在对象进行初始化时进行使用。

  • 在传统 C++ 中,不同的对象有着不同的初始化方法,例如普通数组、 POD (Plain Old Data,即没有构造、析构和虚函数的类或结构体) 类型都可以使用 {} 进行初始化,也就是我们所说的初始化列表。 而对于类对象的初始化,要么需要通过拷贝构造、要么就需要使用 () 进行。 这些不同方法都针对各自对象,不能通用。

  • 例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    #include <iostream>
    #include <vector>

    class Foo {
    public:
    int value_a;
    int value_b;
    Foo(int a, int b) : value_a(a), value_b(b) {}
    };

    int main() {
    // before C++11
    int arr[3] = {1, 2, 3};
    Foo foo(1, 2);
    std::vector<int> vec = {1, 2, 3, 4, 5};

    std::cout << "arr[0]: " << arr[0] << std::endl;
    std::cout << "foo:" << foo.value_a << ", " << foo.value_b << std::endl;
    for (std::vector<int>::iterator it = vec.begin(); it != vec.end(); ++it) {
    std::cout << *it << std::endl;
    }
    return 0;
    }
  • 为解决这个问题,C++11 首先把初始化列表的概念绑定到类型上,称其为 std::initializer_list,允许构造函数或其他函数像参数一样使用初始化列表,这就为类对象的初始化与普通数组和 POD 的初始化方法提供了统一的桥梁,例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    #include <initializer_list>
    #include <vector>
    #include <iostream>

    class MagicFoo {
    public:
    std::vector<int> vec;
    MagicFoo(std::initializer_list<int> list) {
    for (std::initializer_list<int>::iterator it = list.begin();
    it != list.end(); ++it)
    vec.push_back(*it);
    }
    };
    int main() {
    // after C++11
    MagicFoo magicFoo = {1, 2, 3, 4, 5};

    std::cout << "magicFoo: ";
    for (std::vector<int>::iterator it = magicFoo.vec.begin();
    it != magicFoo.vec.end(); ++it)
    std::cout << *it << std::endl;
    }
  • 这种构造函数被叫做初始化列表构造函数,具有这种构造函数的类型将在初始化时被特殊关照

  • 初始化列表除了用在对象构造上,还能将其作为普通函数的形参,例如:

    1
    2
    3
    4
    5
    6
    7
    public:
    void foo(std::initializer_list<int> list) {
    for (std::initializer_list<int>::iterator it = list.begin();
    it != list.end(); ++it) vec.push_back(*it);
    }

    magicFoo.foo({6,7,8,9});
  • 其次,C++11 还提供了统一的语法来初始化任意的对象,例如:Foo foo2 {3, 4};

结构化绑定

  • 结构化绑定提供了类似其他语言中提供的多返回值的功能。在容器一章中,我们会学到 C++11 新增了 std::tuple 容器用于构造一个元组,进而囊括多个返回值。但缺陷是,C++11/14 并没有提供一种简单的方法直接从元组中拿到并定义元组中的元素,尽管我们可以使用 std::tie 对元组进行拆包,但我们依然必须非常清楚这个元组包含多少个对象,各个对象是什么类型,非常麻烦
  • C++17 完善了这一设定,给出的结构化绑定可以让我们写出这样的代码:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    #include <iostream>
    #include <tuple>

    std::tuple<int, double, std::string> f() {
    return std::make_tuple(1, 2.3, "456");
    }

    int main() {
    auto [x, y, z] = f();
    std::cout << x << ", " << y << ", " << z << std::endl;
    return 0;
    }

2.3 类型推导

  • 在传统 C 和 C++ 中,参数的类型都必须明确定义,这其实对我们快速进行编码没有任何帮助,尤其是当我们面对一大堆复杂的模板类型时,必须明确的指出变量的类型才能进行后续的编码,这不仅拖慢我们的开发效率,也让代码变得又臭又长
  • C++11 引入了 auto 和 decltype 这两个关键字实现了类型推导,让编译器来操心变量的类型。这使得 C++ 也具有了和其他现代编程语言一样,某种意义上提供了无需操心变量类型的使用习惯

auto

  • auto 在很早以前就已经进入了 C++,但是他始终作为一个存储类型的指示符存在,与 register 并存。在传统 C++ 中,如果一个变量没有声明为 register 变量,将自动被视为一个 auto 变量。而随着 register 被弃用(在 C++17 中作为保留关键字,以后使用,目前不具备实际意义),对 auto 的语义变更也就非常自然了

  • 使用 auto 进行类型推导的一个最为常见而且显著的例子就是迭代器。你应该在前面的小节里看到了传统 C++ 中冗长的迭代写法:

    1
    2
    3
    4
    // 在 C++11 之前
    // 由于 cbegin() 将返回 vector<int>::const_iterator
    // 所以 it 也应该是 vector<int>::const_iterator 类型
    for(vector<int>::const_iterator it = vec.cbegin(); it != vec.cend(); ++it)
  • 而有了 auto 之后可以:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    #include <initializer_list>
    #include <vector>
    #include <iostream>

    class MagicFoo {
    public:
    std::vector<int> vec;
    MagicFoo(std::initializer_list<int> list) {
    // 从 C++11 起, 使用 auto 关键字进行类型推导
    for (auto it = list.begin(); it != list.end(); ++it) {
    vec.push_back(*it);
    }
    }
    };
    int main() {
    MagicFoo magicFoo = {1, 2, 3, 4, 5};
    std::cout << "magicFoo: ";
    for (auto it = magicFoo.vec.begin(); it != magicFoo.vec.end(); ++it) {
    std::cout << *it << ", ";
    }
    std::cout << std::endl;
    return 0;
    }
  • 一些其他的常见用法:

    • auto i = 5; // i 被推导为 int
    • auto arr = new auto(10); // arr 被推导为 int *
  • 从 C++ 20 起,auto 甚至能用于函数传参,考虑下面的例子:

    1
    2
    3
    4
    5
    6
    7
    int add(auto x, auto y) {
    return x+y;
    }

    auto i = 5; // 被推导为 int
    auto j = 6; // 被推导为 int
    std::cout << add(i, j) << std::endl;
  • 注意:auto 还不能用于推导数组类型:

    1
    2
    3
    4
    auto auto_arr2[10] = {arr}; // 错误, 无法推导数组元素类型

    2.6.auto.cpp:30:19: error: 'auto_arr2' declared as array of 'auto'
    auto auto_arr2[10] = {arr};

decltype

  • decltype 关键字是为了解决 auto 关键字只能对变量进行类型推导的缺陷而出现的。它的用法和 typeof 很相似:decltype(表达式)
  • 有时候,我们可能需要计算某个表达式的类型,例如:
    1
    2
    3
    auto x = 1;
    auto y = 2;
    decltype(x+y) z;
  • 你已经在前面的例子中看到 decltype 用于推断类型的用法,下面这个例子就是判断上面的变量 x, y, z 是否是同一类型:
    1
    2
    3
    4
    5
    6
    if (std::is_same<decltype(x), int>::value)
    std::cout << "type x == int" << std::endl;
    if (std::is_same<decltype(x), float>::value)
    std::cout << "type x == float" << std::endl;
    if (std::is_same<decltype(x), decltype(z)>::value)
    std::cout << "type z == type x" << std::endl;
  • 其中,std::is_same<T, U> 用于判断 T 和 U 这两个类型是否相等。输出结果为:
    • type x == int
    • type z == type x

尾返回类型推导

  • 你可能会思考,在介绍 auto 时,我们已经提过 auto 不能用于函数形参进行类型推导,那么 auto 能不能用于推导函数的返回类型呢?还是考虑一个加法函数的例子,在传统 C++ 中我们必须这么写:

    1
    2
    3
    4
    template<typename R, typename T, typename U>
    R add(T x, U y) {
    return x+y;
    }
  • 注意:typename 和 class 在模板参数列表中没有区别,在 typename 这个关键字出现之前,都是使用 class 来定义模板参数的。但在模板中定义有嵌套依赖类型的变量时,需要用 typename 消除歧义

  • 这样的代码其实变得很丑陋,因为程序员在使用这个模板函数的时候,必须明确指出返回类型。但事实上我们并不知道 add() 这个函数会做什么样的操作,以及获得一个什么样的返回类型

  • 在 C++11 中这个问题得到解决。虽然你可能马上会反应出来使用 decltype 推导 x+y 的类型,写出这样的代码:decltype(x + y) add( T x, U y)

  • 但事实上这样的写法并不能通过编译。这是因为在编译器读到 decltype(x+y) 时,x 和 y 尚未被定义。为了解决这个问题,C++11 还引入了一个叫做尾返回类型(trailing return type),利用 auto 关键字将返回类型后置:

    1
    2
    3
    4
    template<typename T, typename U>
    auto add2(T x, U y) -> decltype(x+y){
    return x + y;
    }
  • 令人欣慰的是从 C++14 开始是可以直接让普通函数具备返回值推导,因此下面的写法变得合法:

    1
    2
    3
    4
    template<typename T, typename U>
    auto add3(T x, U y){
    return x + y;
    }
  • 可以检查一下类型推导是否正确:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // after c++11
    auto w = add2<int, double>(1, 2.0);
    if (std::is_same<decltype(w), double>::value) {
    std::cout << "w is double: ";
    }
    std::cout << w << std::endl;

    // after c++14
    auto q = add3<double, int>(1.0, 2);
    std::cout << "q: " << q << std::endl;

decltype(auto)

  • decltype(auto) 是 C++14 开始提供的一个略微复杂的用法。要理解它你需要知道 C++ 中参数转发的概念,我们会在语言运行时强化一章中详细介绍,你可以到时再回来看这一小节的内容。

  • 简单来说,decltype(auto) 主要用于对转发函数或封装的返回类型进行推导,它使我们无需显式的指定 decltype 的参数表达式。考虑看下面的例子,当我们需要对下面两个函数进行封装时:

    • std::string lookup1();
    • std::string& lookup2();
  • 在 C++11 中,封装实现是如下形式:

    1
    2
    3
    4
    5
    6
    std::string look_up_a_string_1() {
    return lookup1();
    }
    std::string& look_up_a_string_2() {
    return lookup2();
    }
  • 而有了 decltype(auto),我们可以让编译器完成这一件烦人的参数转发:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
        decltype(auto) look_up_a_string_1() {
    return lookup1();
    }
    decltype(auto) look_up_a_string_2() {
    return lookup2();
    }
    ```

    ### 2.4 控制流

    #### if constexpr

    + 正如本章开头出,我们知道了 C++11 引入了 constexpr 关键字,它将表达式或函数编译为常量结果。一个很自然的想法是,如果我们把这一特性引入到条件判断中去,让代码在编译时就完成分支判断,岂不是能让程序效率更高?C++17 将 constexpr 这个关键字引入到 if 语句中,允许在代码中声明常量表达式的判断条件,考虑下面的代码:

    #include

    template
    auto print_type_info(const T& t) {
    if constexpr (std::is_integral::value) {
    return t + 1;
    } else {
    return t + 0.001;
    }
    }
    int main() {
    std::cout << print_type_info(5) << std::endl;
    std::cout << print_type_info(3.14) << std::endl;
    }

    1
    + 在编译时,实际代码就会表现为如下:

    int print_type_info(const int& t) {
    return t + 1;
    }
    double print_type_info(const double& t) {
    return t + 0.001;
    }
    int main() {
    std::cout << print_type_info(5) << std::endl;
    std::cout << print_type_info(3.14) << std::endl;
    }

    1
    2
    3
    4

    #### 区间for迭代

    + 终于,C++11 引入了基于范围的迭代写法,我们拥有了能够写出像 Python 一样简洁的循环语句,我们可以进一步简化前面的例子:

    #include
    #include
    #include

    int main() {
    std::vector vec = {1, 2, 3, 4};
    if (auto itr = std::find(vec.begin(), vec.end(), 3); itr != vec.end()) *itr = 4;
    for (auto element : vec)
    std::cout << element << std::endl; // read only
    for (auto &element : vec) {
    element += 1; // writeable
    }
    for (auto element : vec)
    std::cout << element << std::endl; // read only
    }

    1
    2
    3
    4
    5
    6
    7
    8
    9

    ### 2.5 模板

    + C++ 的模板一直是这门语言的一种特殊的艺术,模板甚至可以独立作为一门新的语言来进行使用。模板的哲学在于将一切能够在编译期处理的问题丢到编译期进行处理,仅在运行时处理那些最核心的动态服务,进而大幅优化运行期的性能。因此模板也被很多人视作 C++ 的黑魔法之一。

    #### 外部模板

    + 传统 C++ 中,模板只有在使用时才会被编译器实例化。换句话说,只要在每个编译单元(文件)中编译的代码中遇到了被完整定义的模板,都会实例化。这就产生了重复实例化而导致的编译时间的增加。并且,我们没有办法通知编译器不要触发模板的实例化。
    + 为此,C++11 引入了外部模板,扩充了原来的强制编译器在特定位置实例化模板的语法,使我们能够显式的通知编译器何时进行模板的实例化:

    template class std::vector; // 强行实例化
    extern template class std::vector; // 不在该当前编译文件中实例化模板

    1
    2
    3
    4
    5

    #### 尖括号`">"`

    + 在传统 C++ 的编译器中,>>一律被当做右移运算符来进行处理。但实际上我们很容易就写出了嵌套模板的代码:`std::vector<std::vector<int>> matrix;`
    + 这在传统 C++ 编译器下是不能够被编译的,而 C++11 开始,连续的右尖括号将变得合法,并且能够顺利通过编译。甚至于像下面这种写法都能够通过编译:

    template
    class MagicType {
    bool magic = T;
    };

    // in main function:
    std::vector<MagicType<(1>2)>> magic; // 合法, 但不建议写出这样的代码

    1
    2
    3
    4

    #### 类型别名模板

    + 在了解类型别名模板之前,需要理解『模板』和『类型』之间的不同。仔细体会这句话:模板是用来产生类型的。在传统 C++ 中,typedef 可以为类型定义一个新的名称,但是却没有办法为模板定义一个新的名称。因为,模板不是类型。例如:

    template<typename T, typename U>
    class MagicType {
    public:
    T dark;
    U magic;
    };

    // 不合法
    template
    typedef MagicType<std::vector, std::string> FakeDarkMagic;

    1
    2
    3

    + C++11 使用 using 引入了下面这种形式的写法,并且同时支持对传统 typedef 相同的功效
    + 通常我们使用 typedef 定义别名的语法是:`typedef 原名称 新名称;`,但是对函数指针等别名的定义语法却不相同,这通常给直接阅读造成了一定程度的困难。

    typedef int (*process)(void );
    using NewProcess = int(
    )(void *);
    template
    using TrueDarkMagic = MagicType<std::vector, std::string>;

    int main() {
    TrueDarkMagic you;
    }

    1
    2
    3
    4
    5
    6

    #### 变成参数模板

    + 模板一直是 C++ 所独有的黑魔法(一起念:Dark Magic)之一。 在 C++11 之前,无论是类模板还是函数模板,都只能按其指定的样子, 接受一组固定数量的模板参数;而 C++11 加入了新的表示方法, 允许任意个数、任意类别的模板参数,同时也不需要在定义时将参数的个数固定。
    + `template<typename... Ts> class Magic;`
    + 模板类 Magic 的对象,能够接受不受限制个数的 typename 作为模板的形式参数,例如下面的定义:

    class Magic<int,
    std::vector,
    std::map<std::string,
    std::vector>> darkMagic;

    1
    2
    3
    4
    5
    6
    7
    8
    + 既然是任意形式,所以个数为 0 的模板参数也是可以的:`class Magic<> nothing;`。
    + 如果不希望产生的模板参数个数为 0,可以手动的定义至少一个模板参数:
    + `template<typename Require, typename... Args> class Magic;`
    + 变长参数模板也能被直接调整到到模板函数上。传统 C 中的 printf 函数, 虽然也能达成不定个数的形参的调用,但其并非类别安全。 而 C++11 除了能定义类别安全的变长参数函数外, 还可以使类似 printf 的函数能自然地处理非自带类别的对象。 除了在模板参数中能使用 ... 表示不定长模板参数外, 函数参数也使用同样的表示法代表不定长参数, 这也就为我们简单编写变长参数函数提供了便捷的手段,例如:
    + `template<typename... Args> void printf(const std::string &str, Args... args);`

    + 那么我们定义了变长的模板参数,如何对参数进行解包呢?
    + 首先,我们可以使用 sizeof... 来计算参数的个数,:
    template<typename... Ts>
    void magic(Ts... args) {
        std::cout << sizeof...(args) << std::endl;
    }
    
    1
    + 我们可以传递任意个参数给 magic 函数:
    magic(); // 输出0
    magic(1); // 输出1
    magic(1, ""); // 输出2
    
    1
    2
    3
    + 其次,对参数进行解包,到目前为止还没有一种简单的方法能够处理参数包,但有两种经典的处理手法:
    1. 递归模板函数
    + 递归是非常容易想到的一种手段,也是最经典的处理方法。这种方法不断递归地向函数传递模板参数,进而达到递归遍历所有模板参数的目的:
        #include <iostream>
        template<typename T0>
        void printf1(T0 value) {
            std::cout << value << std::endl;
        }
        template<typename T, typename... Ts>
        void printf1(T value, Ts... args) {
            std::cout << value << std::endl;
            printf1(args...);
        }
        int main() {
            printf1(1, 2, "123", 1.1);
            return 0;
        }
      
    1
    2
    2. 变参模板展开
    + 你应该感受到了这很繁琐,在 C++17 中增加了变参模板展开的支持,于是你可以在一个函数中完成 printf 的编写:
    template<typename T0, typename... T> void printf2(T0 t0, T... t) { std::cout << t0 << std::endl; if constexpr (sizeof...(t) > 0) printf2(t...); }
    1
    2
    3
    4
       + 事实上,有时候我们虽然使用了变参模板,却不一定需要对参数做逐个遍历,我们可以利用 std::bind 及完美转发等特性实现对函数和参数的绑定,从而达到成功调用的目的。
    3. 初始化列表展开
    + 递归模板函数是一种标准的做法,但缺点显而易见的在于必须定义一个终止递归的函数。
    + 这里介绍一种使用初始化列表展开的黑魔法:
    template<typename T, typename... Ts> auto printf3(T value, Ts... args) { std::cout << value << std::endl; (void) std::initializer_list<T>{([&args] { std::cout << args << std::endl; }(), value)...}; }
    1
    2
    3
    4
    5
    6
            + 在这个代码中,额外使用了 C++11 中提供的初始化列表以及 Lambda 表达式的特性(下一节中将提到)。
    + 通过初始化列表,(lambda 表达式, value)... 将会被展开。由于逗号表达式的出现,首先会执行前面的 lambda 表达式,完成参数的输出。 为了避免编译器警告,我们可以将 std::initializer_list 显式的转为 void

    #### 折叠表达式

    + C++ 17 中将变长参数这种特性进一步带给了表达式,考虑下面这个例子:

    #include
    template<typename … T>
    auto sum(T … t) {
    return (t + …);
    }
    int main() {
    std::cout << sum(1, 2, 3, 4, 5, 6, 7, 8, 9, 10) << std::endl;
    }

    1
    2
    3
    4

    #### 非类型模板参数推导

    + 前面我们主要提及的是模板参数的一种形式:类型模板参数。

    template <typename T, typename U>
    auto add(T t, U u) {
    return t+u;
    }

    1
    + 其中模板的参数 T 和 U 为具体的类型。 但还有一种常见模板参数形式可以让不同字面量成为模板参数,即非类型模板参数:

    template <typename T, int BufSize>
    class buffer_t {
    public:
    T& alloc();
    void free(T& item);
    private:
    T data[BufSize];
    }

    buffer_t<int, 100> buf; // 100 作为模板参数

    1
    + 在这种模板参数形式下,我们可以将 100 作为模板的参数进行传递。 在 C++11 引入了类型推导这一特性后,我们会很自然的问,既然此处的模板参数 以具体的字面量进行传递,能否让编译器辅助我们进行类型推导, 通过使用占位符 auto 从而不再需要明确指明类型? 幸运的是,C++17 引入了这一特性,我们的确可以 auto 关键字,让编译器辅助完成具体类型的推导, 例如:

    template void foo() {
    std::cout << value << std::endl;
    return;
    }

    int main() {
    foo<10>(); // value 被推导为 int 类型
    }

    1
    2
    3
    4
    5
    6

    ### 2.6 面向对象

    #### 委托构造

    + C++11 引入了委托构造的概念,这使得构造函数可以在同一个类中一个构造函数调用另一个构造函数,从而达到简化代码的目的:

    #include
    class Base {
    public:
    int value1;
    int value2;
    Base() {
    value1 = 1;
    }
    Base(int value) : Base() { // 委托 Base() 构造函数
    value2 = value;
    }
    };

    int main() {
    Base b(2);
    std::cout << b.value1 << std::endl;
    std::cout << b.value2 << std::endl;
    }

    1
    2
    3
    4

    #### 继承构造

    + 在传统 C++ 中,构造函数如果需要继承是需要将参数一一传递的,这将导致效率低下。C++11 利用关键字 using 引入了继承构造函数的概念:

    #include
    class Base {
    public:
    int value1;
    int value2;
    Base() {
    value1 = 1;
    }
    Base(int value) : Base() { // 委托 Base() 构造函数
    value2 = value;
    }
    };
    class Subclass : public Base {
    public:
    using Base::Base; // 继承构造
    };
    int main() {
    Subclass s(3);
    std::cout << s.value1 << std::endl;
    std::cout << s.value2 << std::endl;
    }

    1
    2
    3
    4

    #### 显式虚函数重载

    + 在传统 C++ 中,经常容易发生意外重载虚函数的事情。例如:

    struct Base {
    virtual void foo();
    };
    struct SubClass: Base {
    void foo();
    };

    1
    2
    3
    4
    + `SubClass::foo` 可能并不是程序员尝试重载虚函数,只是恰好加入了一个具有相同名字的函数。另一个可能的情形是,当基类的虚函数被删除后,子类拥有旧的函数就不再重载该虚拟函数并摇身一变成为了一个普通的类方法,这将造成灾难性的后果

    + C++11 引入了 override 和 final 这两个关键字来防止上述情形的发生。
    + 当重载虚函数时,引入 override 关键字将显式的告知编译器进行重载,编译器将检查基函数是否存在这样的虚函数,否则将无法通过编译:

    struct Base {
    virtual void foo(int);
    };
    struct SubClass: Base {
    virtual void foo(int) override; // 合法
    virtual void foo(float) override; // 非法, 父类没有此虚函数
    };

    1
    + final 则是为了防止类被继续继承以及终止虚函数继续重载引入的。

    struct Base {
    virtual void foo() final;
    };
    struct SubClass1 final: Base {
    }; // 合法

    struct SubClass2 : SubClass1 {
    }; // 非法, SubClass1 已 final

    struct SubClass3: Base {
    void foo(); // 非法, foo 已 final
    };

    1
    2
    3
    4
    5
    6
    7

    #### 显式禁用默认函数

    + 在传统 C++ 中,如果程序员没有提供,编译器会默认为对象生成默认构造函数、 复制构造、赋值算符以及析构函数。 另外,C++ 也为所有类定义了诸如 new delete 这样的运算符。 当程序员有需要时,可以重载这部分函数
    + 这就引发了一些需求:无法精确控制默认函数的生成行为。 例如禁止类的拷贝时,必须将复制构造函数与赋值算符声明为 private。 尝试使用这些未定义的函数将导致编译或链接错误,则是一种非常不优雅的方式
    + 并且,编译器产生的默认构造函数与用户定义的构造函数无法同时存在。 若用户定义了任何构造函数,编译器将不再生成默认构造函数, 但有时候我们却希望同时拥有这两种构造函数,这就造成了尴尬
    + C++11 提供了上述需求的解决方案,允许显式的声明采用或拒绝编译器自带的函数。 例如:

    class Magic {
    public:
    Magic() = default; // 显式声明使用编译器生成的构造
    Magic& operator=(const Magic&) = delete; // 显式声明拒绝编译器生成构造
    Magic(int magic_number);
    }

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12

    #### 强类型枚举

    + 在传统 C++中,枚举类型并非类型安全,枚举类型会被视作整数,则会让两种完全不同的枚举类型可以进行直接的比较(虽然编译器给出了检查,但并非所有),甚至同一个命名空间中的不同枚举类型的枚举值名字不能相同,这通常不是我们希望看到的结果
    + C++11 引入了枚举类(enumeration class),并使用 enum class 的语法进行声明:
    ```c++
    enum class new_enum : unsigned int {
    value1,
    value2,
    value3 = 100,
    value4 = 100
    };
  • 这样定义的枚举实现了类型安全,首先他不能够被隐式的转换为整数,同时也不能够将其与整数数字进行比较, 更不可能对不同的枚举类型的枚举值进行比较。但相同枚举值之间如果指定的值相同,那么可以进行比较:

    1
    2
    3
    4
    if (new_enum::value3 == new_enum::value4) {
    // 会输出
    std::cout << "new_enum::value3 == new_enum::value4" << std::endl;
    }
  • 在这个语法中,枚举类型后面使用了冒号及类型关键字来指定枚举中枚举值的类型,这使得我们能够为枚举赋值(未指定时将默认使用 int)

  • 而我们希望获得枚举值的值时,将必须显式的进行类型转换,不过我们可以通过重载 << 这个算符来进行输出,可以收藏下面这个代码段:

    1
    2
    3
    4
    5
    6
    7
    8
    #include <iostream>
    template<typename T>
    std::ostream& operator<<(
    typename std::enable_if<std::is_enum<T>::value,
    std::ostream>::type& stream, const T& e)
    {
    return stream << static_cast<typename std::underlying_type<T>::type>(e);
    }
  • 这时,下面的代码将能够被编译:

    • std::cout << new_enum::value3 << std::endl

总结

  • 本节介绍了现代 C++ 中对语言可用性的增强,其中笔者认为最为重要的几个特性是几乎所有人都需要了解并熟练使用的:
    • auto 类型推导
    • 范围 for 迭代
    • 初始化列表
    • 变参模板

第三章 语言运行期的强化

3.1 Lambda表达式

  • Lambda 表达式是现代 C++ 中最重要的特性之一,而 Lambda 表达式,实际上就是提供了一个类似匿名函数的特性, 而匿名函数则是在需要一个函数,但是又不想费力去命名一个函数的情况下去使用的。这样的场景其实有很多很多, 所以匿名函数几乎是现代编程语言的标配。

基础

  • Lambda 表达式的基本语法如下:

    1
    2
    3
    [捕获列表](参数列表) mutable(可选) 异常属性 -> 返回类型 {
    // 函数体
    }
  • 上面的语法规则除了 [捕获列表] 内的东西外,其他部分都很好理解,只是一般函数的函数名被略去, 返回值使用了一个 -> 的形式进行(我们在上一节前面的尾返回类型已经提到过这种写法了)

  • 所谓捕获列表,其实可以理解为参数的一种类型,Lambda 表达式内部函数体在默认情况下是不能够使用函数体外部的变量的, 这时候捕获列表可以起到传递外部数据的作用。根据传递的行为,捕获列表也分为以下几种:

    • 值捕获
      • 与参数传值类似,值捕获的前提是变量可以拷贝,不同之处则在于,被捕获的变量在 Lambda 表达式被创建时拷贝, 而非调用时才拷贝:
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        void lambda_value_capture() {
        int value = 1;
        auto copy_value = [value] {
        return value;
        };
        value = 100;
        auto stored_value = copy_value();
        std::cout << "stored_value = " << stored_value << std::endl;
        // 这时, stored_value == 1, 而 value == 100.
        // 因为 copy_value 在创建时就保存了一份 value 的拷贝
        }
    • 引用捕获
      • 与引用传参类似,引用捕获保存的是引用,值会发生变化。
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        void lambda_reference_capture() {
        int value = 1;
        auto copy_value = [&value] {
        return value;
        };
        value = 100;
        auto stored_value = copy_value();
        std::cout << "stored_value = " << stored_value << std::endl;
        // 这时, stored_value == 100, value == 100.
        // 因为 copy_value 保存的是引用
        }
    • 隐式捕获
      • 手动书写捕获列表有时候是非常复杂的,这种机械性的工作可以交给编译器来处理,这时候可以在捕获列表中写一个 & 或 = 向编译器声明采用引用捕获或者值捕获.
  • 总结一下,捕获提供了 Lambda 表达式对外部值进行使用的功能,捕获列表的最常用的四种形式可以是:

    • [] 空捕获列表
    • [name1, name2, ...] 捕获一系列变量
    • [&] 引用捕获, 让编译器自行推导引用列表
    • [=] 值捕获, 让编译器自行推导值捕获列表
  • 表达式捕获。(这部分内容需要了解后面马上要提到的右值引用以及智能指针)

  • 上面提到的值捕获、引用捕获都是已经在外层作用域声明的变量,因此这些捕获方式捕获的均为左值,而不能捕获右值。

  • C++14 给与了我们方便,允许捕获的成员用任意的表达式进行初始化,这就允许了右值的捕获, 被声明的捕获变量类型会根据表达式进行判断,判断方式与使用 auto 本质上是相同的:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    #include <iostream>
    #include <memory> // std::make_unique
    #include <utility> // std::move

    void lambda_expression_capture() {
    auto important = std::make_unique<int>(1);
    auto add = [v1 = 1, v2 = std::move(important)](int x, int y) -> int {
    return x+y+v1+(*v2);
    };
    std::cout << add(3,4) << std::endl;
    }
  • 在上面的代码中,important 是一个独占指针,是不能够被 “=” 值捕获到,这时候我们可以将其转移为右值,在表达式中初始化。

泛型Lambda

  • 上一节中我们提到了 auto 关键字不能够用在参数表里,这是因为这样的写法会与模板的功能产生冲突。 但是 Lambda 表达式并不是普通函数,所以在没有明确指明参数表类型的情况下,Lambda 表达式并不能够模板化。 幸运的是,这种麻烦只存在于 C++11 中,从 C++14 开始,Lambda 函数的形式参数可以使用 auto 关键字来产生意义上的泛型:
    1
    2
    3
    4
    5
    6
    auto add = [](auto x, auto y) {
    return x+y;
    };

    add(1, 2);
    add(1.1, 2.2);

3.2 函数对象包装器

  • 这部分内容虽然属于标准库的一部分,但是从本质上来看,它却增强了 C++ 语言运行时的能力, 这部分内容也相当重要,所以放到这里来进行介绍。

std::function

  • Lambda 表达式的本质是一个和函数对象类型相似的类类型(称为闭包类型)的对象(称为闭包对象), 当 Lambda 表达式的捕获列表为空时,闭包对象还能够转换为函数指针值进行传递,例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    #include <iostream>

    using foo = void(int); // 定义函数类型, using 的使用见上一节中的别名语法
    void functional(foo f) { // 参数列表中定义的函数类型 foo 被视为退化后的函数指针类型 foo*
    f(1); // 通过函数指针调用函数
    }

    int main() {
    auto f = [](int value) {
    std::cout << value << std::endl;
    };
    functional(f); // 传递闭包对象,隐式转换为 foo* 类型的函数指针值
    f(1); // lambda 表达式调用
    return 0;
    }
  • 上面的代码给出了两种不同的调用形式,一种是将 Lambda 作为函数类型传递进行调用, 而另一种则是直接调用 Lambda 表达式,在 C++11 中,统一了这些概念,将能够被调用的对象的类型, 统一称之为可调用类型。而这种类型,便是通过 std::function 引入的

  • C++11 std::function 是一种通用、多态的函数封装, 它的实例可以对任何可以调用的目标实体进行存储、复制和调用操作, 它也是对 C++ 中现有的可调用实体的一种类型安全的包裹(相对来说,函数指针的调用不是类型安全的), 换句话说,就是函数的容器。当我们有了函数的容器之后便能够更加方便的将函数、函数指针作为对象进行处理。 例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    #include <functional>
    #include <iostream>

    int foo(int para) {
    return para;
    }

    int main() {
    // std::function 包装了一个返回值为 int, 参数为 int 的函数
    std::function<int(int)> func = foo;

    int important = 10;
    std::function<int(int)> func2 = [&](int value) -> int {
    return 1+value+important;
    };
    std::cout << func(10) << std::endl;
    std::cout << func2(10) << std::endl;
    }

std::bind 和 std::placeholder

  • 而 std::bind 则是用来绑定函数调用的参数的, 它解决的需求是我们有时候可能并不一定能够一次性获得调用某个函数的全部参数,通过这个函数, 我们可以将部分调用参数提前绑定到函数身上成为一个新的对象,然后在参数齐全后,完成调用。 例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    int foo(int a, int b, int c) {
    ;
    }
    int main() {
    // 将参数1,2绑定到函数 foo 上,
    // 但使用 std::placeholders::_1 来对第一个参数进行占位
    auto bindFoo = std::bind(foo, std::placeholders::_1, 1,2);
    // 这时调用 bindFoo 时,只需要提供第一个参数即可
    bindFoo(1);
    }
  • 提示:注意 auto 关键字的妙用。有时候我们可能不太熟悉一个函数的返回值类型, 但是我们却可以通过 auto 的使用来规避这一问题的出现。

3.3 右值引用

  • 右值引用是 C++11 引入的与 Lambda 表达式齐名的重要特性之一。它的引入解决了 C++ 中大量的历史遗留问题, 消除了诸如 std::vector、std::string 之类的额外开销, 也才使得函数对象容器 std::function 成为了可能。

左值, 右值的纯右值,将亡值,右值

  • 要弄明白右值引用到底是怎么一回事,必须要对左值和右值做一个明确的理解。

    • 左值 (lvalue, left value),顾名思义就是赋值符号左边的值。准确来说, 左值是表达式(不一定是赋值表达式)后依然存在的持久对象。
    • 右值 (rvalue, right value),右边的值,是指表达式结束后就不再存在的临时对象。
  • 而 C++11 中为了引入强大的右值引用,将右值的概念进行了进一步的划分,分为:纯右值、将亡值。

    • 纯右值 (prvalue, pure rvalue),纯粹的右值,要么是纯粹的字面量,例如 10, true; 要么是求值结果相当于字面量或匿名临时对象,例如 1+2。非引用返回的临时变量、运算表达式产生的临时变量、 原始字面量、Lambda 表达式都属于纯右值
  • 需要注意的是,字面量除了字符串字面量以外,均为纯右值。而字符串字面量是一个左值,类型为 const char 数组。例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    #include <type_traits>

    int main() {
    // 正确,"01234" 类型为 const char [6],因此是左值
    const char (&left)[6] = "01234";

    // 断言正确,确实是 const char [6] 类型,注意 decltype(expr) 在 expr 是左值
    // 且非无括号包裹的 id 表达式与类成员表达式时,会返回左值引用
    static_assert(std::is_same<decltype("01234"), const char(&)[6]>::value, "");

    // 错误,"01234" 是左值,不可被右值引用
    // const char (&&right)[6] = "01234";
    }
  • 但是注意,数组可以被隐式转换成相对应的指针类型,而转换表达式的结果(如果不是左值引用)则一定是个右值(右值引用为将亡值,否则为纯右值)。例如:

    1
    2
    3
    4
    5
    6
    7
        const char*   p   = "01234";  // 正确,"01234" 被隐式转换为 const char*
    const char*&& pr = "01234"; // 正确,"01234" 被隐式转换为 const char*,该转换的结果是纯右值
    // const char*& pl = "01234"; // 错误,此处不存在 const char* 类型的左值
    ```

    + 将亡值 (xvalue, expiring value),是 C++11 为了引入右值引用而提出的概念(因此在传统 C++ 中, 纯右值和右值是同一个概念),也就是即将被销毁、却能够被移动的值。
    + 将亡值可能稍有些难以理解,我们来看这样的代码:

    std::vector foo() {
    std::vector temp = {1, 2, 3, 4};
    return temp;
    }

    std::vector v = foo();

    1
    2
    3
    4
    5
    6
    7
    8
    9
    + 在这样的代码中,就传统的理解而言,函数 foo 的返回值 temp 在内部创建然后被赋值给 v, 然而 v 获得这个对象时,会将整个 temp 拷贝一份,然后把 temp 销毁,如果这个 temp 非常大, 这将造成大量额外的开销(这也就是传统 C++ 一直被诟病的问题)。
    + 在最后一行中,v 是左值、 foo() 返回的值就是右值(也是纯右值)。但是,v 可以被别的变量捕获到, 而 foo() 产生的那个返回值作为一个临时值,一旦被 v 复制后,将立即被销毁,无法获取、也不能修改。 而将亡值就定义了这样一种行为:临时的值能够被识别、同时又能够被移动

    + 在 C++11 之后,编译器为我们做了一些工作,此处的左值 temp 会被进行此隐式右值转换, 等价于 `static_cast<std::vector<int> &&>(temp)`,进而此处的 v 会将 foo 局部返回的值进行移动。 也就是后面我们将会提到的移动语义。

    #### 右值引用和左值引用

    + 要拿到一个将亡值,就需要用到右值引用:`T &&`,其中 T 是类型。 右值引用的声明让这个临时值的生命周期得以延长、只要变量还活着,那么将亡值将继续存活。
    + C++11 提供了 std::move 这个方法将左值参数无条件的转换为右值, 有了它我们就能够方便的获得一个右值临时对象,例如:

    #include
    #include

    void reference(std::string& str) {
    std::cout << “左值” << std::endl;
    }
    void reference(std::string&& str) {
    std::cout << “右值” << std::endl;
    }

    int main()
    {
    std::string lv1 = “string,”; // lv1 是一个左值
    // std::string&& r1 = lv1; // 非法, 右值引用不能引用左值
    std::string&& rv1 = std::move(lv1); // 合法, std::move可以将左值转移为右值
    std::cout << rv1 << std::endl; // string,

    const std::string& lv2 = lv1 + lv1; // 合法, 常量左值引用能够延长临时变量的生命周期
    // lv2 += “Test”; // 非法, 常量引用无法被修改
    std::cout << lv2 << std::endl; // string,string,

    std::string&& rv2 = lv1 + lv2; // 合法, 右值引用延长临时对象生命周期
    rv2 += “Test”; // 合法, 非常量引用能够修改临时变量
    std::cout << rv2 << std::endl; // string,string,string,Test

    reference(rv2); // 输出左值

    return 0;
    }

    1
    2
    + rv2 虽然引用了一个右值,但由于它是一个引用,所以 rv2 依然是一个左值。
    + 注意,这里有一个很有趣的历史遗留问题,我们先看下面的代码:

    #include

    int main() {
    // int &a = std::move(1); // 不合法,非常量左引用无法引用右值
    const int &b = std::move(1); // 合法, 常量左引用允许引用右值

    std::cout << a << b << std::endl;
    }

    1
    + 第一个问题,为什么不允许非常量引用绑定到非左值?这是因为这种做法存在逻辑错误:

    void increase(int & v) {
    v++;
    }
    void foo() {
    double s = 1;
    increase(s);
    }

    1
    2
    3
    4
    5
    6
    7
    8
    9
    + 由于 int& 不能引用 double 类型的参数,因此必须产生一个临时值来保存 s 的值, 从而当 increase() 修改这个临时值时,调用完成后 s 本身并没有被修改。
    + 第二个问题,为什么常量引用允许绑定到非左值?原因很简单,因为 Fortran 需要

    #### 移动语义

    + 传统 C++ 通过拷贝构造函数和赋值操作符为类对象设计了拷贝/复制的概念,但为了实现对资源的移动操作, 调用者必须使用先复制、再析构的方式,否则就需要自己实现移动对象的接口。
    + 试想,搬家的时候是把家里的东西直接搬到新家去,而不是将所有东西复制一份(重买)再放到新家、 再把原来的东西全部扔掉(销毁),这是非常反人类的一件事情。

    + 传统的 C++ 没有区分『移动』和『拷贝』的概念,造成了大量的数据拷贝,浪费时间和空间。 右值引用的出现恰好就解决了这两个概念的混淆问题,例如:

    #include
    class A {
    public:
    int *pointer;
    A():pointer(new int(1)) {
    std::cout << “构造” << pointer << std::endl;
    }
    A(A& a):pointer(new int(*a.pointer)) {
    std::cout << “拷贝” << pointer << std::endl;
    } // 无意义的对象拷贝
    A(A&& a):pointer(a.pointer) {
    a.pointer = nullptr;
    std::cout << “移动” << pointer << std::endl;
    }
    ~A(){
    std::cout << “析构” << pointer << std::endl;
    delete pointer;
    }
    };
    // 防止编译器优化
    A return_rvalue(bool test) {
    A a,b;
    if(test) return a; // 等价于 static_cast<A&&>(a);
    else return b; // 等价于 static_cast<A&&>(b);
    }
    int main() {
    A obj = return_rvalue(false);
    std::cout << “obj:” << std::endl;
    std::cout << obj.pointer << std::endl;
    std::cout << *obj.pointer << std::endl;
    return 0;
    }

    1
    2
    3
    4
    + 在上面的代码中:
    + 首先会在 return_rvalue 内部构造两个 A 对象,于是获得两个构造函数的输出;
    + 函数返回后,产生一个将亡值,被 A 的移动构造(A(A&&))引用,从而延长生命周期,并将这个右值中的指针拿到,保存到了 obj 中,而将亡值的指针被设置为 nullptr,防止了这块内存区域被销毁。
    + 从而避免了无意义的拷贝构造,加强了性能。再来看看涉及标准库的例子:

    #include // std::cout
    #include // std::move
    #include // std::vector
    #include // std::string

    int main() {

      std::string str = "Hello world.";
      std::vector<std::string> v;
    
      // 将使用 push_back(const T&), 即产生拷贝行为
      v.push_back(str);
      // 将输出 "str: Hello world."
      std::cout << "str: " << str << std::endl;
    
      // 将使用 push_back(const T&&), 不会出现拷贝行为
      // 而整个字符串会被移动到 vector 中,所以有时候 std::move 会用来减少拷贝出现的开销
      // 这步操作后, str 中的值会变为空
      v.push_back(std::move(str));
      // 将输出 "str: "
      std::cout << "str: " << str << std::endl;
    
      return 0;
    

    }

    1
    2
    3
    4

    #### 完美转发

    + 前面我们提到了,一个声明的右值引用其实是一个左值。这就为我们进行参数转发(传递)造成了问题:

    void reference(int& v) {
    std::cout << “左值” << std::endl;
    }
    void reference(int&& v) {
    std::cout << “右值” << std::endl;
    }
    template
    void pass(T&& v) {
    std::cout << “普通传参:”;
    reference(v); // 始终调用 reference(int&)
    }
    int main() {
    std::cout << “传递右值:” << std::endl;
    pass(1); // 1是右值, 但输出是左值

    std::cout << “传递左值:” << std::endl;
    int l = 1;
    pass(l); // l 是左值, 输出左值

    return 0;
    }

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    + 对于 pass(1) 来说,虽然传递的是右值,但由于 v 是一个引用,所以同时也是左值。 因此 reference(v) 会调用 reference(int&),输出『左值』。 而对于pass(l)而言,l是一个左值,为什么会成功传递给 pass(T&&) 呢?
    + 这是基于引用坍缩规则的:在传统 C++ 中,我们不能够对一个引用类型继续进行引用, 但 C++ 由于右值引用的出现而放宽了这一做法,从而产生了引用坍缩规则,允许我们对引用进行引用, 既能左引用,又能右引用。但是却遵循如下规则:

    | 函数形参类型 | 实参参数类型 | 推倒后函数形参类型 |
    | :--- | :--- | :--- |
    | T& | 左引用 | T& |
    | T& | 右引用 | T& |
    | T&& | 左引用 | T& |
    | T&& | 右引用 | T&& |

    + 因此,模板函数中使用 T&& 不一定能进行右值引用,当传入左值时,此函数的引用将被推导为左值。 更准确的讲,无论模板参数是什么类型的引用,当且仅当实参类型为右引用时,模板参数才能被推导为右引用类型。 这才使得 v 作为左值的成功传递
    + 完美转发就是基于上述规律产生的。所谓完美转发,就是为了让我们在传递参数的时候, 保持原来的参数类型(左引用保持左引用,右引用保持右引用)。 为了解决这个问题,我们应该使用 std::forward 来进行参数的转发(传递):

    #include
    #include
    void reference(int& v) {
    std::cout << “左值引用” << std::endl;
    }
    void reference(int&& v) {
    std::cout << “右值引用” << std::endl;
    }
    template
    void pass(T&& v) {
    std::cout << “ 普通传参: “;
    reference(v);
    std::cout << “ std::move 传参: “;
    reference(std::move(v));
    std::cout << “ std::forward 传参: “;
    reference(std::forward(v));
    std::cout << “static_cast<T&&> 传参: “;
    reference(static_cast<T&&>(v));
    }
    int main() {
    std::cout << “传递右值:” << std::endl;
    pass(1);

    std::cout << “传递左值:” << std::endl;
    int v = 1;
    pass(v);

    return 0;
    }

    1
    + 输出结果为:

    传递右值:
    普通传参: 左值引用
    std::move 传参: 右值引用
    std::forward 传参: 右值引用
    static_cast<T&&> 传参: 右值引用
    传递左值:
    普通传参: 左值引用
    std::move 传参: 右值引用
    std::forward 传参: 左值引用
    static_cast<T&&> 传参: 左值引用

    1
    2
    3
    4
    5
    6

    + 无论传递参数为左值还是右值,普通传参都会将参数作为左值进行转发, 所以 `std::move` 总会接受到一个左值,从而转发调用了reference(int&&) 输出右值引用。
    + 唯独 `std::forward` 即没有造成任何多余的拷贝,同时完美转发(传递)了函数的实参给了内部调用的其他函数。
    + `std::forward` 和 `std::move` 一样,没有做任何事情,`std::move` 单纯的将左值转化为右值, `std::forward` 也只是单纯的将参数做了一个类型的转换,从现象上来看, `std::forward<T>(v)` 和 `static_cast<T&&>(v)` 是完全一样的。

    + 读者可能会好奇,为何一条语句能够针对两种类型的返回对应的值, 我们再简单看一看 `std::forward` 的具体实现机制,`std::forward` 包含两个重载:

    template
    constexpr _Tp&& forward(typename std::remove_reference<_Tp>::type& __t) noexcept
    { return static_cast<_Tp&&>(__t); }

    template
    constexpr _Tp&& forward(typename std::remove_reference<_Tp>::type&& __t) noexcept
    {
    static_assert(!std::is_lvalue_reference<_Tp>::value, “template argument”
    “ substituting _Tp is an lvalue reference type”);
    return static_cast<_Tp&&>(__t);
    }

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    + 在这份实现中,`std::remove_reference` 的功能是消除类型中的引用,
    + `std::is_lvalue_reference` 则用于检查类型推导是否正确,在 `std::forward` 的第二个实现中 检查了接收到的值确实是一个左值,进而体现了坍缩规则。
    + 当 `std::forward` 接受左值时,_Tp 被推导为左值,所以返回值为左值;而当其接受右值时, _Tp 被推导为 右值引用,则基于坍缩规则,返回值便成为了 && + && 的右值。 可见 `std::forward` 的原理在于巧妙的利用了模板类型推导中产生的差异。
    + 这时我们能回答这样一个问题:为什么在使用循环语句的过程中,auto&& 是最安全的方式? 因为当 auto 被推导为不同的左右引用时,与 && 的坍缩组合是完美转发

    ### 总结

    + 本章介绍了现代 C++ 中最为重要的几个语言运行时的增强,其中笔者认为本节中提到的所有特性都是值得掌握的:
    + Lambda 表达式
    + 函数对象容器 `std::function`
    + 右值引用

    ## 第四章 容器

    ### 4.1 线性容器

    #### std::array

    + 看到这个容器的时候肯定会出现这样的问题:
    + 为什么要引入 std::array 而不是直接使用 std::vector
    + 已经有了传统数组,为什么要用 std::array

    + 先回答第一个问题,与 std::vector 不同,std::array 对象的大小是固定的,如果容器大小是固定的,那么可以优先考虑使用 std::array 容器。
    + 另外由于 std::vector 是自动扩容的,当存入大量的数据后,并且对容器进行了删除操作, 容器并不会自动归还被删除元素相应的内存,这时候就需要手动运行 shrink_to_fit() 释放这部分内存:

    std::vector v;
    std::cout << “size:” << v.size() << std::endl; // 输出 0
    std::cout << “capacity:” << v.capacity() << std::endl; // 输出 0

    // 如下可看出 std::vector 的存储是自动管理的,按需自动扩张
    // 但是如果空间不足,需要重新分配更多内存,而重分配内存通常是性能上有开销的操作
    v.push_back(1);
    v.push_back(2);
    v.push_back(3);
    std::cout << “size:” << v.size() << std::endl; // 输出 3
    std::cout << “capacity:” << v.capacity() << std::endl; // 输出 4

    // 这里的自动扩张逻辑与 Golang 的 slice 很像
    v.push_back(4);
    v.push_back(5);
    std::cout << “size:” << v.size() << std::endl; // 输出 5
    std::cout << “capacity:” << v.capacity() << std::endl; // 输出 8

    // 如下可看出容器虽然清空了元素,但是被清空元素的内存并没有归还
    v.clear();
    std::cout << “size:” << v.size() << std::endl; // 输出 0
    std::cout << “capacity:” << v.capacity() << std::endl; // 输出 8

    // 额外内存可通过 shrink_to_fit() 调用返回给系统
    v.shrink_to_fit();
    std::cout << “size:” << v.size() << std::endl; // 输出 0
    std::cout << “capacity:” << v.capacity() << std::endl; // 输出 0

    1
    2
    3
    4

    + 而第二个问题就更加简单,使用 std::array 能够让代码变得更加“现代化”,而且封装了一些操作函数,比如获取数组大小以及检查是否非空,同时还能够友好的使用标准库中的容器算法,比如 std::sort

    + 使用 std::array 很简单,只需指定其类型和大小即可:

    std::array<int, 4> arr = {1, 2, 3, 4};

    arr.empty(); // 检查容器是否为空
    arr.size(); // 返回容纳的元素数

    // 迭代器支持
    for (auto &i : arr)
    {
    // …
    }

    // 用 lambda 表达式排序
    std::sort(arr.begin(), arr.end(), [](int a, int b) {
    return b < a;
    });

    // 数组大小参数必须是常量表达式
    constexpr int len = 4;
    std::array<int, len> arr = {1, 2, 3, 4};

    // 非法,不同于 C 风格数组,std::array 不会自动退化成 T*
    // int *arr_p = arr;

    1
    + 当我们开始用上了 std::array 时,难免会遇到要将其兼容 C 风格的接口,这里有三种做法:

    void foo(int *p, int len) {
    return;
    }

    std::array<int, 4> arr = {1,2,3,4};

    // C 风格接口传参
    // foo(arr, arr.size()); // 非法, 无法隐式转换
    foo(&arr[0], arr.size());
    foo(arr.data(), arr.size());

    // 使用 std::sort
    std::sort(arr.begin(), arr.end());

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12

    #### std::forward_list

    + std::forward_list 是一个列表容器,使用方法和 std::list 基本类似,因此我们就不花费篇幅进行介绍了。
    + 需要知道的是,和 std::list 的双向链表的实现不同,std::forward_list 使用单向链表进行实现, 提供了 O(1) 复杂度的元素插入,不支持快速随机访问(这也是链表的特点), 也是标准库容器中唯一一个不提供 size() 方法的容器。当不需要双向迭代时,具有比 std::list 更高的空间利用率。

    ### 4.2 无序容器

    + 我们已经熟知了传统 C++ 中的有序容器 std::map/std::set,这些元素内部通过红黑树进行实现, 插入和搜索的平均复杂度均为 O(log(size))。在插入元素时候,会根据 < 操作符比较元素大小并判断元素是否相同, 并选择合适的位置插入到容器中。当对这个容器中的元素进行遍历时,输出结果会按照 < 操作符的顺序来逐个遍历。
    + 而无序容器中的元素是不进行排序的,内部通过 Hash 表实现,插入和搜索元素的平均复杂度为 O(constant), 在不关心容器内部元素顺序时,能够获得显著的性能提升。
    + C++11 引入了的两组无序容器分别是:`std::unordered_map/std::unordered_multimap` 和 `std::unordered_set/std::unordered_multiset`
    + 它们的用法和原有的 `std::map/std::multimap/std::set/set::multiset` 基本类似, 由于这些容器我们已经很熟悉了,便不一一举例,我们直接来比较一下`std::map`和`std::unordered_map`:

    #include
    #include
    #include
    #include

    int main() {
    // 两组结构按同样的顺序初始化
    std::unordered_map<int, std::string> u = {
    {1, “1”},
    {3, “3”},
    {2, “2”}
    };
    std::map<int, std::string> v = {
    {1, “1”},
    {3, “3”},
    {2, “2”}
    };

    // 分别对两组结构进行遍历
    std::cout << “std::unordered_map” << std::endl;
    for( const auto & n : u)
    std::cout << “Key:[“ << n.first << “] Value:[“ << n.second << “]\n”;

    std::cout << std::endl;
    std::cout << “std::map” << std::endl;
    for( const auto & n : v)
    std::cout << “Key:[“ << n.first << “] Value:[“ << n.second << “]\n”;
    }

    1
    + 最终输出的结果为:

    std::unordered_map
    Key:[2] Value:[2]
    Key:[3] Value:[3]
    Key:[1] Value:[1]

    std::map
    Key:[1] Value:[1]
    Key:[2] Value:[2]
    Key:[3] Value:[3]

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13

    ### 4.3 元组

    + 了解过 Python 的程序员应该知道元组的概念,纵观传统 C++ 中的容器,除了 std::pair 外, 似乎没有现成的结构能够用来存放不同类型的数据(通常我们会自己定义结构)。 但 std::pair 的缺陷是显而易见的,只能保存两个元素。

    #### 元组基本操作

    + 关于元组的使用有三个核心的函数:
    + std::make_tuple: 构造元组
    + std::get: 获得元组某个位置的值
    + std::tie: 元组拆包

    + 示例:

    #include
    #include

    auto get_student(int id)
    {
    // 返回类型被推断为 std::tuple<double, char, std::string>

    if (id == 0)
    return std::make_tuple(3.8, ‘A’, “张三”);
    if (id == 1)
    return std::make_tuple(2.9, ‘C’, “李四”);
    if (id == 2)
    return std::make_tuple(1.7, ‘D’, “王五”);
    return std::make_tuple(0.0, ‘D’, “null”);
    // 如果只写 0 会出现推断错误, 编译失败
    }

    int main()
    {
    auto student = get_student(0);
    std::cout << “ID: 0, “
    << “GPA: “ << std::get<0>(student) << “, “
    << “成绩: “ << std::get<1>(student) << “, “
    << “姓名: “ << std::get<2>(student) << ‘\n’;

    double gpa;
    char grade;
    std::string name;

    // 元组进行拆包
    std::tie(gpa, grade, name) = get_student(1);
    std::cout << “ID: 1, “
    << “GPA: “ << gpa << “, “
    << “成绩: “ << grade << “, “
    << “姓名: “ << name << ‘\n’;
    }

    1
    2

    + std::get 除了使用常量获取元组对象外,C++14 增加了使用类型来获取元组中的对象:

    std::tuple<std::string, double, double, int> t(“123”, 4.5, 6.7, 8);
    std::cout << std::getstd::string(t) << std::endl;
    std::cout << std::get(t) << std::endl; // 非法, 引发编译期错误
    std::cout << std::get<3>(t) << std::endl;

    1
    2
    3
    4

    #### 运行期索引

    + 如果你仔细思考一下可能就会发现上面代码的问题,std::get<> 依赖一个编译期的常量,所以下面的方式是不合法的:

    int index = 1;
    std::get(t);

    1
    + 那么要怎么处理?答案是,使用 std::variant<>(C++ 17 引入),提供给 variant<> 的类型模板参数 可以让一个 variant<> 从而容纳提供的几种类型的变量(在其他语言,例如 Python/JavaScript 等,表现为动态类型):

    #include
    template <size_t n, typename… T>
    constexpr std::variant<T…> _tuple_index(const std::tuple<T…>& tpl, size_t i) {
    if constexpr (n >= sizeof…(T))
    throw std::out_of_range(“越界.”);
    if (i == n)
    return std::variant<T…>{ std::in_place_index, std::get(tpl) };
    return _tuple_index<(n < sizeof…(T)-1 ? n+1 : 0)>(tpl, i);
    }
    template <typename… T>
    constexpr std::variant<T…> tuple_index(const std::tuple<T…>& tpl, size_t i) {
    return _tuple_index<0>(tpl, i);
    }
    template <typename T0, typename … Ts>
    std::ostream & operator<< (std::ostream & s, std::variant<T0, Ts…> const & v) {
    std::visit([&](auto && x){ s << x;}, v);
    return s;
    }

    1
    + 这样我们就能:

    int i = 1;
    std::cout << tuple_index(t, i) << std::endl;

    1
    2
    3
    4
    5
    6
    7

    #### 元组合并与遍历

    + 还有一个常见的需求就是合并两个元组,这可以通过 std::tuple_cat 来实现:
    + `auto new_tuple = std::tuple_cat(get_student(1), std::move(t));`

    + 马上就能够发现,应该如何快速遍历一个元组?但是我们刚才介绍了如何在运行期通过非常数索引一个 tuple 那么遍历就变得简单了, 首先我们需要知道一个元组的长度,可以:

    template
    auto tuple_len(T &tpl) {
    return std::tuple_size::value;
    }

    1
    + 这样就能够对元组进行迭代了:

    // 迭代
    for(int i = 0; i != tuple_len(new_tuple); ++i)
    // 运行期索引
    std::cout << tuple_index(new_tuple, i) << std::endl;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20

    ### 总结

    + 本章简单介绍了现代 C++ 中新增的容器,它们的用法和传统 C++ 中已有的容器类似,相对简单,可以根据实际场景丰富的选择需要使用的容器,从而获得更好的性能。
    + std::tuple 虽然有效,但是标准库提供的功能有限,没办法满足运行期索引和迭代的需求,好在我们还有其他的方法可以自行实现。

    ## 第五章 智能指针与内存管理

    ### 5.1 RAII与引用计数

    + 引用计数这种计数是为了防止内存泄露而产生的。 基本想法是对于动态分配的对象,进行引用计数,每当增加一次对同一个对象的引用,那么引用对象的引用计数就会增加一次, 每删除一次引用,引用计数就会减一,当一个对象的引用计数减为零时,就自动删除指向的堆内存。
    + 在传统 C++ 中,『记得』手动释放资源,总不是最佳实践。因为我们很有可能就忘记了去释放资源而导致泄露。 所以通常的做法是对于一个对象而言,我们在构造函数的时候申请空间,而在析构函数(在离开作用域时调用)的时候释放空间, 也就是我们常说的 **RAII 资源获取即初始化技术**
    + 凡事都有例外,我们总会有需要将对象在自由存储上分配的需求,在传统 C++ 里我们只好使用 new 和 delete 去 『记得』对资源进行释放。而 C++11 引入了智能指针的概念,使用了引用计数的想法,让程序员不再需要关心手动释放内存。 这些智能指针包括 `std::shared_ptr/std::unique_ptr/std::weak_ptr`,使用它们需要包含头文件 `<memory>`
    + 注意:引用计数不是垃圾回收,引用计数能够尽快收回不再被使用的对象,同时在回收的过程中也不会造成长时间的等待, 更能够清晰明确的表明资源的生命周期。

    ### 5.2 std::shared_ptr

    + `std::shared_ptr` 是一种智能指针,它能够记录多少个 shared_ptr 共同指向一个对象,从而消除显式的调用 delete,当引用计数变为零的时候就会将对象自动删除。但还不够,因为使用 `std::shared_ptr` 仍然需要使用 new 来调用,这使得代码出现了某种程度上的不对称。
    + `std::make_shared` 就能够用来消除显式的使用 new,所以 `std::make_shared` 会分配创建传入参数中的对象, 并返回这个对象类型的 `std::shared_ptr` 指针
    + 例如:

    #include
    #include
    void foo(std::shared_ptr i) {
    (*i)++;
    }
    int main() {
    // auto pointer = new int(10); // illegal, no direct assignment
    // Constructed a std::shared_ptr
    auto pointer = std::make_shared(10);
    foo(pointer);
    std::cout << *pointer << std::endl; // 11
    // The shared_ptr will be destructed before leaving the scope
    return 0;
    }

    1
    2
    3
    4
    5
    6

    + `std::shared_ptr` 可以通过 `get()` 方法来获取原始指针,通过 `reset()` 来减少一个引用计数, 并通过 `use_count()` 来查看一个对象的引用计数

    ### 5.3 std::unique_str

    + `std::unique_ptr` 是一种独占的智能指针,它禁止其他智能指针与其共享同一个对象,从而保证代码的安全:

    std::unique_ptr pointer = std::make_unique(10); // make_unique 从 C++14 引入
    std::unique_ptr pointer2 = pointer; // 非法

    1
    + make_unique 并不复杂,C++11 没有提供 std::make_unique,可以自行实现:

    template<typename T, typename …Args>
    std::unique_ptr make_unique( Args&& …args ) {
    return std::unique_ptr( new T( std::forward(args)… ) );
    }

    1
    2
    3

    + 既然是独占,换句话说就是不可复制。但是,我们可以利用 `std::move` 将其转移给其他的 `unique_ptr`
    + 例如:

    #include
    #include

    struct Foo {
    Foo() { std::cout << “Foo::Foo” << std::endl; }
    Foo() { std::cout << “Foo::Foo” << std::endl; }
    void foo() { std::cout << “Foo::foo” << std::endl; }
    };

    void f(const Foo &) {
    std::cout << “f(const Foo&)” << std::endl;
    }

    int main() {
    std::unique_ptr p1(std::make_unique());
    // p1 不空, 输出
    if (p1) p1->foo();
    {
    std::unique_ptr p2(std::move(p1));
    // p2 不空, 输出
    f(*p2);
    // p2 不空, 输出
    if(p2) p2->foo();
    // p1 为空, 无输出
    if(p1) p1->foo();
    p1 = std::move(p2);
    // p2 为空, 无输出
    if(p2) p2->foo();
    std::cout << “p2 被销毁” << std::endl;
    }
    // p1 不空, 输出
    if (p1) p1->foo();
    // Foo 的实例会在离开作用域时被销毁
    }

    1
    2
    3
    4

    ### 5.4 std::weak_ptr

    + 如果你仔细思考 `std::shared_ptr` 就会发现依然存在着资源无法释放的问题。看下面这个例子:

    struct A;
    struct B;

    struct A {
    std::shared_ptr pointer;
    ~A() {
    std::cout << “A 被销毁” << std::endl;
    }
    };
    struct B {
    std::shared_ptr pointer;
    ~B() {
    std::cout << “B 被销毁” << std::endl;
    }
    };
    int main() {
    auto a = std::make_shared
    ();
    auto b = std::make_shared();
    a->pointer = b;
    b->pointer = a;
    }

    
    
  • 运行结果是 A, B 都不会被销毁,这是因为 a,b 内部的 pointer 同时又引用了 a,b,这使得 a,b 的引用计数均变为了 2,而离开作用域时,a,b 智能指针被析构,却只能造成这块区域的引用计数减一,这样就导致了 a,b 对象指向的内存区域引用计数不为零,而外部已经没有办法找到这块区域了,也就造成了内存泄露

  • 解决这个问题的办法就是使用弱引用指针 std::weak_ptrstd::weak_ptr 是一种弱引用(相比较而言 std::shared_ptr 就是一种强引用)

  • std::weak_ptr 没有 * 运算符和 -> 运算符,所以不能够对资源进行操作,它可以用于检查 std::shared_ptr 是否存在,其 expired() 方法能在资源未被释放时,会返回 false,否则返回 true

  • 除此之外,它也可以用于获取指向原始对象的 std::shared_ptr 指针,其 lock() 方法在原始对象未被释放时,返回一个指向原始对象的 std::shared_ptr 指针,进而访问原始对象的资源,否则返回 nullptr

简介

  • FFmpeg框架的基本组成包含AVFormat, AVCodec, AVFilter, AVDevice, AVUtil等模块库。

  • FFmpeg的封装模块AVFormat

    • AVFormat中实现了目前多媒体领域中的绝大多数媒体封装格式,包括封装和解封装,例如MP4,FLV,KV,TS等文件封装格式,RTMP,RTSP,MMS,HLS等网络协议封装格式。
    • FFmpeg是否支持某种媒体封装格式,取决于编译时是否包含了该格式的封装库
  • FFmpeg的编解码模块AVCodec

    • AVCodec中实现了目前多媒体领域绝大多数常用的编解码格式,既支持编码,也支持解码。AVCodec除了支持MJPEG4,AAC,MJPEG等自带的媒体编解码格式之外,还支持第三方的编解码器,例如H.264(AVC)编码,需要使用x264编码器;H.265(HEVC)编码,需要使用x265编码器;MP3(mp3lame)编码,需要使用libmp3lame编码器。
    • 如果希望增加自己的编码格式,或者硬件编码,则需要在AVCodec中增加相应的编解码模块。
  • FFmpeg的滤镜模块AVFilter

    • AVFilter库提供了一个通用的音频,视频,字幕等滤镜过滤框架。在AVFilter中,滤镜框架可以有多个输入和多个输出。
    • 示例:
      • ./ffmpeg -i INPUT -vf “split [main][tmp]; [tmp] crop=iw:ih/2:0:0, vflip [flip];[main][flip] overlay=0:H/2” OUTPUT
    • 相同的Filter线性链之间用逗号分隔
    • 不同的Filter线性链之间用分号分隔
    • 在以上示例中,crop与vflip使用的时同一个滤镜处理的线性链,split滤镜和overlay滤镜使用的是另一个线性链,一个线性链与另一个线性链汇合时是通过方括号[]括起来的标签进行标示的。在这个例子中,两个流处理后是通过[main]与[flip]进行关联汇合的。
    • split滤镜将分割后的视频流的第二部分打上标签[tmp],通过crop滤镜对该部分流进行处理,然后进行纵坐标调换操作,打赏标签[flip],然后将[main]标签与[flip]标签进行合并,[flip]标签的视频流从视频的左边最中间的位置开始显示,这样就出现了镜像效果
  • FFmpeg的视频图像转换计算模块swscale

    • swscale模块提供了高级别的图像转换API,例如它允许图像缩放和像素格式转换,常见于将图像从1080p转换成720p或者480p的缩放,或者将图像数据从YUV420p转换成YUYV,或者YUV转RGB等图像格式转换。
  • FFmpeg的音频转换计算模块swresample

    • swresample模块提供了高级别的音频重采样API。例如它允许操作音频采样,音频通道布局与布局调整

FFmpeg的编解码工具ffmpeg

  • ffmpeg是FFmpeg源代码编译后生成的一个可执行程序,其可以作为命令行工具使用。

  • ffmpeg的主要工作流程相对比较简单,具体如下:

    • 解封装(Demuxing)
    • 解码(Decoding)
    • 编码(Encoding)
    • 封装(Muxing)
  • 其中需要经过6个步骤,具体如下:

    • 读取输入源
    • 进行音视频的解封装
    • 解码每一帧音视频数据
    • 编码每一帧音视频数据
    • 进行音视频重新封装
    • 输出到目标
  • ffmpeg首先读取输入源;然后通过Demuxer将音视频包进行解封装,这个动作通过调用libavformat中的接口即可实现;

  • 接下来通过Decoder进行解码,将音视频通过Decoder解包成为YVU或者PCM这样的数据,Decoder通过libavcodec中的接口即可实现

  • 然后通过Encoder将对应的数据进行编码,编码可以通过libavcodec中的接口来实现

  • 接下来将编码后的音视频数据包通过Muxer进行封装,Muxer封装通过libavformat中的接口即可实现,输出成为输出流

FFmpeg的播放器ffplay

  • 使用FFmpeg的avformat与avcodec,可以播放各种媒体文件或者流。如果想要使用ffplay,那么系统首先需要有SDL来进行ffplay的基础支撑

  • ffplay是FFmpeg源代码编译后生成的另一个可执行程序,与ffmpeg在FFmpeg项目中充当的角色基本相同,可以作为测试工具进行使用,ffplay提供了音视频显示和播放相关的图像信息,音频的波形信息等。

FFmpeg的多媒体分析器ffprobe

  • ffprobe也是FFmpeg源码编译后生成的一个可执行程序。ffprobe是一个非常强大的多媒体分析工具,可以从媒体文件或者媒体流中获得想要了解的信息,例如音频的参数,视频的参数,媒体容器的参数信息等。
  • 使用ffprobe可以分析媒体文件中每个包的长度,包的类型,帧的信息等

FFmpeg编译

  • FFmpeg在官方网站中提供已经编译好的可执行文件。

FFmpeg之Windows平台编译

  • FFmpeg在Windows平台中的编译需要使用MinGW-w64,MinGW是Minimalist GNU for Windows的缩写,提供了一系列的工具链来辅助编译Windows的本地化程序

  • MinGW-w64单独使用起来会比较麻烦,但是其可以与MSYS环境配合使用,MSYS是Minimal SYStem的缩写,其主要完成的工作为UNIX on Windows的功能。显而易见,这是一个仿生UNIX环境的Windows工具集

FFmpeg编码支持与定制

  • FFmpeg本身支持一些音视频编码格式,文件封装格式与流媒体传输协议,但是支持的数量依然有限,FFmpeg所做的只是提供一套基础的框架,所有的编码格式,文件封装格式和流媒体协议均可以作为FFmpeg的一个模块挂在在FFmpeg框架中。这些模块以第三方的外部库的方式提供支持,可以通过FFmpeg源码的configure命令查看FFmpeg所支持的音视频编码格式,文件封装格式与 流媒体传输协议,对于FFmpeg不支持的格式,可以通过configure –help查看所需要的第三方外部库,然后通过增加对应的编译参数选项进行支持。

  • FFmpeg默认支持额音视频编码格式,文件封装格式和流媒体传输协议相对来说比较多,因此编译出来的FFmpeg体积比较大,在有些应用场景中,并不需要FFmpeg所支持的一些编码,封装或者协议。可以通过configure –help查看一些有用的裁剪操作

  • FFmpeg的编码器支持

    • FFmpeg源代码中可以包含的编码非常多,常见的和不常见的都可以在编译配置列表中见到,可以通过使用编译配置命令./configure –list-encoders参数来查看
  • FFmpeg的解码器支持

    • FFmpeg源代码本身包含了很多的解码支持,解码主要是在输入的时候进行解码,也可以理解为将压缩过的编码进行解压缩,关于解码的支持,可以通过./configure –list-decoders命令来查看
  • FFmpeg的封装支持

    • FFmpeg的封装(Muxing)是指将压缩后的编码封装到一个容器格式中,如果要查看FFmpeg源代码中都可以支持哪些容器格式,可以通过命令./configure –list-muxers来查看
  • FFmpeg的解封装支持

    • FFmpeg的解封装(Demuxing)是指将读入的容器格式拆解开,将里面的压缩的音频流,视频流,字幕流,数据流等提取出来,如果要查看FFmpeg的源代码中都可以支持哪些输入的容器格式,可以通过命令./configure –list-demuxers来查看
  • FFmpeg的通信协议支持

    • FFmpeg不仅仅支持本地的多媒体处理,而且还支持网络流媒体的处理,支持的网络流媒体协议相对来说也很全面,可以通过命令./configure –list-protocols查看

ffmpeg常用命令

  • ffmpeg在做音视频编解码时非常方便,所以在很多场景下转码使用的时ffmpeg,通过ffmpeg –help可以看到ffmpeg常见的命令大概分为6个部分,具体如下:
    • ffmpeg信息查询部分
    • 公共操作参数部分
    • 文件主要操作参数部分
    • 视频操作参数部分
    • 音频操作参数部分
    • 字幕操作参数部分

ffmpeg的封装转换

  • ffmpeg的封装转换(转封装)功能包含在AVFormat模块中,通过libavformat库进行Mux和Demux操作;多媒体文件的格式有很多种,这些格式中的很多参数在Mux与Demux的操作参数中是公用的。

ffmpeg的转码参数

  • ffmpeg编解码部分的功能主要是通过模块AVCodec来完成的,通过libavcodec库进行Encode和Decode操作。多媒体编码格式的种类有很多,但是还是有很多通用的基本操作参数设置

ffmpeg的基本转码原理

  • ffmpeg工具的主要用途为编码,解码,转码以及媒体格式转换,ffmpeg常用于进行转码操作。

ffprobe常用命令

  • ffprobe主要用来查看多媒体文件的信息。

  • 使用 ffprobe -show_packets input.flv 查看多媒体数据包信息;也可以通过 ffprobe -show_data -show_packets input.flv 组合参数来查看包中的具体数据。

  • 通过 ffprobe -show_format output.mp4 命令可以查看多媒体的封装格式,其使用FORMAT标签括起来显示

  • 通过 ffprobe -show_frames input.flv 命令可以查看视频文件中的帧信息,输出的帧信息将使用FRAME标签括起来

  • 通过 -show_streams 参数可以查看到多媒体文件中的流信息,流的信息将使用STREAM标签括起来

  • ffprobe使用前面的参数可以获得key-value格式的显示方式;如果要进行格式化的显示,这样就需要用到ffprobe -print_format或者ffprobe -of 参数来进行相应的格式输出,而-print_format 支持多种格式输出,包括XML,INI,JSON,CSV,FLAT等

ffplay常用命令

  • 在FFmpeg中通常使用ffplay作为播放器,其实ffplay同样也可以作为很多音视频数据的图形化分析工具,通过ffplay可以看到视频图像的运动方向,音视频数据的波形等

ffplay常用参数

  • ffplay不仅仅是播放器,同时也是测试ffmpeg的codec引擎,format引擎,以及filter引擎的工具,并且还可以进行可视化的媒体参数分析

ffplay的数据可视化分析应用

  • 使用ffplay除了可以播放视频流媒体文件之外,还可以作为可视化的视频流媒体分析工具,例如播放音频文件,如果不确定文件的声音是否正常,则可以直接使用ffplay播放音频文件,播放的时候其将会把解码后的音频数据以音频波形的形式显示出来

音视频文件转MP4格式

  • 在互联网常见的格式中,跨平台最好的应该是MP4文件,因为MP4文件既可以在PC平台的Flashplayer中播放,又可以在移动平台的Android,IOS等平台中进行播放,而且使用系统默认的播放器即可播放,因此我们说MP4格式是最常见的多媒体文件格式。

视频文件转FLV

  • 在网络的直播与点播场景中,FLV也是一种常见的格式,FLV是Adobe发布的一种可以作为直播也可以作为点播的封装格式,其封装格式非常简单,均以FLVTAG的形式存在,并且每一个TAG都是独立存在的。

视频文件转M3U8

  • M3U8是一种常见的流媒体格式,主要以文件列表的形式存在,即支持直播又支持点播,尤其在Android,IOS等平台最为常用

FFmpeg抽取音视频文件中的AAC音频流

  • FFmpeg除了转封装,转码之外,还可以提取音频流,例如需要将音频流提取出来然后合成之后插入到另一个封装中的情况。

  • FFmpeg提取MP4文件中的AAC音频流方法:

    • ./ffmpeg -i input.mp4 -vn -acodec copy output.aac

FFmpeg 硬编解码

  • 当使用FFmpeg进行软编码时,常见的基于CPU进行H.264或H.265编码其相对成本会比较高,CPU编码时的性能也很低,所以出于编码效率及成本考虑,很多时候都会考虑采用硬编码,常见的硬编码包含Nvidia GPU与Intel QSV两种,还有常见的嵌入式平台,例如树莓派,瑞芯微等。

简介

  • C++ Core Guidelines笔记

第二章 理念

在代码中直接表达思想

  • 程序员应该直接用代码直接表达他们的思想,因为代码可以被编译器和工具检查
  • 一个专业的C++开发者应该了解STL算法

用ISO标准C++写代码

  • 使用当前的C++标准,不要使用编译器扩展
  • 此外,要注意未定义行为和实现定义行为
    • 未定义行为:
    • 实现定义行为:程序的行为可能因编译器实现而异。实现必须在文档里描述实现的行为

在C++编程中,有两个重要的概念:未定义行为(Undefined Behavior)和实现定义行为(Implementation Defined Behavior)。

  1. 未定义行为 (Undefined Behavior):

    • 当程序包含未定义行为时,C++标准没有规定程序的行为,允许编译器和运行时环境采用任何行为。这可能导致程序崩溃、产生意外结果、或者在不同的编译器、平台或编译选项下表现不同。
    • 未定义行为可能是由于程序中的错误、溢出、指针操纵等原因引起的,也可能是标准规定没有定义的操作。
  2. 实现定义行为 (Implementation Defined Behavior):

    • 当某个方面的行为是由C++标准定义的,但是标准允许不同的实现在这方面做出不同的选择,这被称为实现定义行为。
    • 例如,标准规定某个操作的结果可以有多种可能,但实现需要选择其中一种并在文档中明确说明。

示例:

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>

int main() {
int arr[5] = {1, 2, 3, 4, 5};
std::cout << arr[5] << std::endl; // 未定义行为,越界访问数组

int x = -5;
unsigned int y = 10;
std::cout << x * y << std::endl; // 实现定义行为,结果取决于具体的实现
return 0;
}

在这个例子中,访问数组arr的第六个元素是未定义行为,因为数组索引越界。而对于x * y的乘法,结果是实现定义的,因为标准并没有规定对于负数和无符号数相乘的具体行为。

在编写C++代码时,尽量避免未定义行为,因为它可能导致代码在不同环境下的行为不可预测。实现定义行为可能会因不同的编译器或平台而异,但至少有一个可预测的结果。

  • 当你必须使用没有写在ISO标准里的扩展时,可以用一个稳定的接口将它们封装起来

表达意图

  • 表达意图是良好的代码文档的一个重要准则。文档应该说明代码会做什么,而不是代码会怎么做

不要泄漏任何资源

  • 资源可以是内存,也可以是文件句柄或者套接字。
  • 处理资源的惯用法是RAII。RAII是 Resource Acquisition Is Initialization(资源获取即初始化)的缩写,本质上意味着你在用户定义类型的构造函数中获取资源,在析构函数中释放资源。通过使对象成为一个有作用于的对象,C++的运行时会自动照顾到资源的生存期
  • C++大量使用RAII:锁负责处理互斥量,智能指针负责处理原始内存,STL的容器负责处理底层元素,等等

不可变数据优先于可变数据

  • 使用不可变数据的理由有很多。
  • 首先,当你使用常量时,你的代码更加容易验证
  • 最重要的是常量在并发程序中具有很大的优势
  • 不可变数据在设计上是没有数据竞争的,因为数据竞争的必要条件就是对数据进行修改

封装杂乱的构件,不要让他在代码中散布开

  • 混乱的代码往往是低级代码易于隐藏错误,容易出现问题。
  • 如果可能的话,用STL中的高级构件(例如容器和算法)来取代你的杂乱代码。
  • 如果这不可能,就把那些杂乱的代码封装带一个用户自定义的类型或者函数中

适当使用辅助工具

  • 计算机比人类更擅长做枯燥和重复性的工作。也就是说,应该使用静态分析工具,并发工具和测试工具来自动完成这些验证。
  • 用一个以上的C++编译器来编译代码,往往是验证代码的最简单方式。一个编译器可能检测不到某种未定义行为,而另一个编译器可能会在同样的情况下发出警告或者产生错误

适当使用支持库

  • 你应该去找设计良好,文档齐全,支持良好的库。
  • 突出的例子包括:
    • C++标准库
    • Guidelines支持的库
    • Boost库

第三章 接口

  • 接口是服务的提供者和使用者之间的契约

避免非const的全局变量

  • 全局变量会在函数中注入隐藏的依赖,而该依赖并不是接口的一部分
  • 非const的全局变量有许多弊端。首先,非const的全局变量破坏了封装。这种对封装的破坏让你无法对函数/类(实体)进行孤立思考。

避免单例

  • 单例就是全局变量,因此你应当尽可能避免单例。单例简单,直接地保证该类最多只有一个实例存在。
  • C++ 单例模式 详解
    单例模式是一种设计模式,其目的是确保一个类只有一个实例,并提供全局访问点。这有助于确保在整个应用程序中共享相同的资源或状态,以及提供一种方便的方法来访问该实例。下面详细解释C++中的单例模式。

实现单例模式的步骤:

  1. 私有构造函数: 将类的构造函数声明为私有,以防止外部直接实例化类。

    1
    2
    3
    4
    class Singleton {
    private:
    Singleton() {} // 私有构造函数
    };
  2. 静态成员变量: 在类中声明一个静态私有指针,用于保存唯一的实例。

    1
    2
    3
    4
    5
    class Singleton {
    private:
    static Singleton* instance; // 静态私有指针
    Singleton() {} // 私有构造函数
    };
  3. 静态方法: 提供一个公共的静态方法,用于获取类的实例。在这个方法中,检查实例是否已经存在,如果不存在,则创建一个新实例并返回。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    class Singleton {
    private:
    static Singleton* instance; // 静态私有指针
    Singleton() {} // 私有构造函数

    public:
    static Singleton* getInstance() {
    if (instance == nullptr) {
    instance = new Singleton();
    }
    return instance;
    }
    };
  4. 删除复制构造函数和赋值运算符: 为了防止通过复制构造函数或赋值运算符创建新实例,将它们声明为私有并不实现。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    class Singleton {
    private:
    static Singleton* instance; // 静态私有指针
    Singleton() {} // 私有构造函数

    public:
    static Singleton* getInstance() {
    if (instance == nullptr) {
    instance = new Singleton();
    }
    return instance;
    }

    private:
    Singleton(const Singleton&); // 禁止复制构造函数
    Singleton& operator=(const Singleton&); // 禁止赋值运算符
    };

线程安全性:

上述实现在单线程环境下是有效的,但在多线程环境中可能会有问题。为了确保线程安全,可以使用加锁机制,例如互斥锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <iostream>
#include <mutex>

class Singleton {
private:
static Singleton* instance; // 静态私有指针
static std::mutex mutex; // 互斥锁
Singleton() {} // 私有构造函数

public:
static Singleton* getInstance() {
std::lock_guard<std::mutex> lock(mutex); // 加锁
if (instance == nullptr) {
instance = new Singleton();
}
return instance;
}

private:
Singleton(const Singleton&); // 禁止复制构造函数
Singleton& operator=(const Singleton&); // 禁止赋值运算符
};

// 初始化静态成员变量
Singleton* Singleton::instance = nullptr;
std::mutex Singleton::mutex;

int main() {
// 获取单例实例
Singleton* singletonInstance1 = Singleton::getInstance();
Singleton* singletonInstance2 = Singleton::getInstance();

std::cout << "Address of instance 1: " << singletonInstance1 << std::endl;
std::cout << "Address of instance 2: " << singletonInstance2 << std::endl;

return 0;
}

这个例子中,通过 std::mutex 实现了简单的互斥锁,确保在多线程环境中仍然能够正确地创建单例实例。

运用依赖注入化解

  • 当某个对象使用单例的时候,隐藏的依赖就被注入对象中。而借助依赖注入技术,这个依赖可以变成接口的一部分,并且服务是从外界注入的。这样,客户代码和注入的服务之间就没有了依赖。
  • 依赖注入的典型方式是构造函数,设置函数(setter)成员或者模板参数

构建良好的接口

  • 函数应该通过接口(而不是全局变量)进行沟通。

  • 接口应当遵循以下规则:

    • 接口明确
    • 接口精确并且具有强类型
    • 保持较低的参数数目
    • 避免相同类型却不相关的参数相邻
  • 术语”可调用”(callable)。可调用实体是在行为上像函数的东西。它可以是函数,也可以是函数对象,或者是lambda表达时。

  • 如果可调用实体接受一个参数,它就是一元可调用实体;如果它接受两个参数,则称为二元可调用实体

  • std::transform_reduce先将一元可调用实体应用到一个范围或者将二元可调用实体应用到两个范围,然后将二元可调用实体应用到前一步的结果的范围上。

不要用单个指针来传递数组

  • 这条规则的出现是为了解决一些未定义行为
  • 补救的方法也简单,使用STL中的容器,例如std::vector,并在函数体中检查容器的大小

为了库ABI的稳定,考虑使用PImpI惯用法

  • 应用程序二进制接口(ABI)是两个二进制程序模块间的接口

  • 借助PImpI惯用法,可以隔离类的用户和实现,从而避免重复编译。

  • PImpI是 pointer to implementation(指向实现的指针)的缩写,它指的是C++中的一种编程技巧:

    • 将实现细节放在另一个类中,从而将其从类中移除。而这个包含实现的细节的类是通过一个指针来访问的。
    • 这么做是因为私有数据成员会参与类的内存布局,而私有函数成员会参与重载决策。这些依赖意味着对成员实现细节的修改会导致所有类的用户都需要重新编译。
    • 持有指向实现的指针(PImpI)的类可将用户隔离在类实现的变化之外,而代价则是多了一次间接。
  • C++ PImpI编程技巧 详解
    PImpl(Pointer to Implementation)是一种编程技巧,也称为“编译期实现”或“内部实现”,它的目的是将类的实现细节封装在一个单独的实现类中,从而减少头文件的依赖关系,提高代码的模块化性和可维护性。

PImpl模式的实现步骤:

  1. 声明外部接口: 在类的头文件中声明类的公共接口,但将实际的成员变量和实现细节的声明放到一个内部类中。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    // MyClass.h
    class MyClass {
    public:
    MyClass();
    ~MyClass();

    void publicMethod1();
    void publicMethod2();

    private:
    class Impl; // 内部实现类的前向声明
    Impl* pImpl; // 内部实现类的指针
    };
  2. 定义实现类: 在实现文件中定义内部实现类,并将实际的成员变量和函数实现放在这里。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    // MyClass.cpp
    #include "MyClass.h"

    // 实现内部实现类
    class MyClass::Impl {
    public:
    void privateMethod1() {
    // 实现细节
    }

    void privateMethod2() {
    // 实现细节
    }

    // 成员变量
    int data;
    };

    // MyClass 构造函数
    MyClass::MyClass() : pImpl(new Impl()) {}

    // MyClass 析构函数
    MyClass::~MyClass() {
    delete pImpl;
    }

    // 公共方法的实现调用内部实现类的方法
    void MyClass::publicMethod1() {
    pImpl->privateMethod1();
    }

    void MyClass::publicMethod2() {
    pImpl->privateMethod2();
    }

PImpl的优势:

  1. 降低编译依赖: 将实现细节从头文件中移除,降低了头文件的依赖关系。这样,当实现发生变化时,只有实现文件需要重新编译,而不会影响到调用方。

  2. 隐藏实现细节: 将实现细节放在内部实现类中,可以隐藏对类的具体实现的细节,只需要暴露公共接口给用户。

  3. 减小编译时间: 当头文件发生变化时,只有依赖头文件的文件需要重新编译,而不会触发整个项目的重新编译。

  4. 改善二进制兼容性: 通过将实现细节放在内部实现类中,可以减少对外部接口的更改,提高二进制兼容性。

  5. 模块化设计: 可以更容易地设计模块化的系统,每个模块只关注自己的接口和实现细节。

注意事项:

  1. 内存管理: 要确保在类的析构函数中正确释放内部实现类的内存,以防止内存泄漏。

  2. 拷贝和赋值: PImpl模式可能导致默认的拷贝构造函数和赋值运算符不再适用,需要自定义这些函数并确保正确处理内部实现类的拷贝和赋值。

  3. 性能开销: PImpl模式引入了指针和额外的间接层,可能会带来一些微小的性能开销,但通常在维护性和可读性上的优势远远超过了这些开销。

PImpl是一种强大的C++编程技巧,特别适用于大型项目和库的开发,有助于提高代码的模块化性和可维护性。

本章精华

  • 不要使用全局变量,它们会引入隐藏的依赖
  • 单例就是变相的全局变量
  • 接口,尤其是函数,应该表达出意图
  • 接口应当是强类型的,而且应该只有几个不容易弄混的参数
  • 不要按指针接收C数组,而应该使用std::span
  • 如果你想要将类的使用和实现分开,请使用PImpI惯用法

第四章 函数

  • 软件开发人员通过将复杂的任务划分为较小的单元来掌控复杂性。在处理完小单元后,他们把小单元放在一起来掌控复杂的任务。
  • 函数是一种典型的单元,也是程序的基本构件。

函数定义

  • 好软件的最重要原则是好名字。

  • 将有意义的操作打包成精心命名的函数

  • 一个函数应该执行单一的逻辑操作

  • 使函数保持简短

  • 当你无法为函数找到一个有意义的名称时,这充分说明你的函数执行不止一项逻辑操作,而且你的函数并不简短

如果函数有可能需要在编译期求值,就把它声明为 constexpr

  • constexpr函数是可能在编译期运行的函数。当你在常量表达式中调用constexpr函数时,或者当你要用一个constexpr变量来获取constexpr函数的结果时,它会在编译期运行。也可以用只能在运行其求值的参数来调用constexpr函。
  • constexpr函数是隐含內联的
  • 编译期求值的constexpr的结果通常会被系统标记为只读
  • 性能是constexpr函数的第一大好处;它的第二大好处是,编译期求值的constexpr函数是纯函数,因此constexpr函数是线程安全的
  • 最后,计算结果会在运行期作为只读存储区域中的常量来提供

如果你的函数必定不抛异常,就把它声明为noexcept

  • 通过将函数声明为noexcept,你减少了备选控制路径的数量;因此,noexcept对优化器来说是一个有价值的提示
  • 即使你的函数可以抛出异常,noexcept往往也合理。noexcept在这种情况下意味着:我不在乎异常。其原因可能是:你无法对异常作出反应。在这种情况下,系统处理异常的唯一办法是调用std::terminate()
  • 以下类型的函数永远不应该抛出异常:
    • 析构函数
    • swap函数
    • 移动操作和默认构造函数

优先使用纯函数

  • 纯函数是指在给定相同参数时总是返回相同结果的函数。这个属性也被称为引用透明性。纯函数的行为就像无限大的查找表

  • 非纯函数是指random()或者time()这样的函数,它们会在不同的调用中返回不同的结果。换句话说,与函数体之外的状态交互的函数是不纯的

  • 纯函数有一些非常有趣的属性

    • 孤立的测试
    • 孤立的验证或重构
    • 还存其结果
    • 被自动重排或者在其他线程上执行
  • 纯函数也常被称为数学函数。

  • constexpr函数在编译期求值时是纯的。模板元编程是一种嵌在命令式语言C++中的纯函数式语言

优先采用简单而约定俗成的信息传递方式

  • 数据的类型:

    • 拷贝开销低或者不可能拷贝构造: func(X)
    • 移动开销低:std::vector, std::string
    • 移动开销中: std::arraystd::vector或者BigPOD(POD代表 Plain Old Data,简旧数据,意为一般的传统数据–没有析构函数,构造函数以及虚成员函数的类)
    • 移动开销未知: 模板
    • 移动开销高:BigPOD[]或者std::array
  • 参数传递的方向

    • 入:输入参数
    • 入并保留拷贝:被调用者保留一份数据
    • 入并移入:参数处在所谓的被移动状态。被移动状态意味着它处于合法但未指定的状态。基本上,你在重新使用被移动的对象前必须对他进行初始化
    • 入/出:参数会被修改
    • 出:输出参数

对于入参,拷贝开销低的类型按值传递,其他类型则以const引用来传递

  • 默认情况下,输入值可以拷贝就拷贝。如果拷贝开销不低,就通过const引用来传入。
  • 经验法则:
    • 如果 sizeof(par) <= 2 * sizeof(void*),则按值传递参数par
    • 如果 sizeof(par) > 2 * sizeof(void*),则按const引用传递par

对于转发参数,要用TP&&来传递,并且只std::forward该参数

  • 有时你想转发参数par。这意味着你希望保持左值的左值性,以及右值的右值性,这样才能完美地转发参数,使它的语义不发生变化
  • 转发参数的典型用例是工厂函数,工厂函数通过调用某个用户指定对象的构造函数创建出该对象。你不知道参数是不是右值,也不知道构造函数需要多少个参数

在C++中,工厂函数是一种设计模式,它提供了一种创建对象的方式,使得在不暴露对象的具体实现细节的情况下能够创建对象。工厂函数通常用于创建类的实例,而不是直接调用类的构造函数。这有助于实现抽象和封装,同时提供了灵活性和可维护性。

以下是关于C++工厂函数的一些详解:

  1. 定义
    工厂函数是一个函数,负责创建和返回类的实例。它通常是类的静态成员函数,或者是一个独立于类的函数。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    class Product {
    public:
    virtual ~Product() {}
    virtual void doSomething() = 0;
    };

    class ConcreteProduct : public Product {
    public:
    void doSomething() override {
    // 具体产品的实现
    }
    };

    class Factory {
    public:
    static Product* createProduct() {
    return new ConcreteProduct();
    }
    };
  2. 返回类型
    工厂函数通常返回一个指向基类(抽象类)的指针或引用,这样可以隐藏具体实现的细节,同时允许客户端代码通过基类接口使用对象。

  3. 抽象类
    工厂函数通常用于创建抽象类的实例,这样可以根据需要选择合适的具体实现。在上面的例子中,Product 是抽象类,而 ConcreteProduct 是它的具体实现。

  4. 灵活性
    工厂函数提供了一种动态创建对象的方式,允许在运行时根据条件或配置选择要创建的具体类型,从而提供更大的灵活性。

    1
    2
    3
    4
    5
    6
    7
    int main() {
    Product* product = Factory::createProduct();
    product->doSomething();
    delete product;

    return 0;
    }
  5. 多态性
    通过返回基类指针,工厂函数支持多态性,允许通过基类接口调用具体类的方法。

  6. 单例工厂
    工厂函数可以实现为单例,确保在应用程序中只存在一个工厂实例,从而确保对对象创建的全局控制。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    class SingletonFactory {
    public:
    static SingletonFactory& getInstance() {
    static SingletonFactory instance;
    return instance;
    }

    Product* createProduct() {
    return new ConcreteProduct();
    }

    private:
    SingletonFactory() {} // 私有构造函数,确保只能通过 getInstance() 访问
    };

总的来说,工厂函数是一种有用的设计模式,它提供了一种灵活、可扩展且易于维护的方式来创建对象,特别是在需要隐藏具体实现的情况下。

  • 三个点(省略号)表示形参包。我们将使用形参包的模板称为变参模板
  • 形参包的打包和解包
    • 当省略号在类型参数T1的左边时,参数包被打包;当省略号在右边时,参数包被解包
    • 返回语句 T(std::forward(t1)…)中的这种解包实质上意味着表达式std::forward(t1)被不断重复,直到形参表的所有参数都被消耗掉,并且会在每个子表达式之间加一个逗号
  • C++ 形参包 详解

在C++中,形参包(parameter pack)是C++11引入的一个特性,它允许你定义一个可以包含任意数量参数的函数或类模板。形参包的主要优势之一是在不知道参数数量的情况下,仍然能够编写通用的代码。

以下是有关C++形参包的详解:

  1. 定义形参包
    形参包使用省略号 ... 表示。它可以用在函数模板或类模板的参数列表中。

    1
    2
    3
    4
    5
    // 函数模板形参包的例子
    template <typename... Args>
    void myFunction(Args... args) {
    // 使用args...
    }

    在上面的例子中,Args 是一个模板参数包,而 args 是函数参数包。

  2. 展开形参包
    通过使用展开操作符 ...,可以在函数体内展开形参包,以便对每个参数执行相同的操作。

    1
    2
    3
    4
    template <typename... Args>
    void printValues(Args... args) {
    (std::cout << ... << args) << '\n'; // 展开形参包,逐个输出参数
    }

    在上面的例子中,(std::cout << ... << args) 部分展开了形参包,逐个将参数传递给 std::cout

  3. 递归展开
    形参包可以用于递归展开,实现对每个参数的逐一处理。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    template <typename T>
    void printSingleValue(const T& value) {
    std::cout << value << '\n';
    }

    template <typename T, typename... Args>
    void printValues(T first, Args... rest) {
    printSingleValue(first); // 处理第一个参数
    printValues(rest...); // 递归展开处理剩余参数
    }

    在上面的例子中,printValues 函数递归展开形参包,对每个参数调用 printSingleValue 函数。

  4. 折叠表达式
    C++17 引入了折叠表达式,使得展开形参包更加简洁。

    1
    2
    3
    4
    template <typename... Args>
    void printValues(Args... args) {
    (std::cout << ... << args) << '\n'; // 折叠表达式
    }

    折叠表达式使得对形参包的处理更加紧凑和易读。

  5. 使用形参包的场景
    形参包通常在需要处理可变数量参数的通用函数或模板中使用,例如元组的操作、可变参数模板、泛型代码等。

形参包是C++中强大的工具,它为编写通用和灵活的代码提供了便利。在处理不定数量参数的场景中,形参包的使用可以显著提高代码的可读性和可维护性。

  • 转发与变参模板的结合是C++中典型的创建模式

对于 入-出 参数,使用非const的引用来传递

  • 这条规则把函数的设计意图传达给了调用法:该函数会修改它的参数

对于 出 的输出值,优先使用返回值而非输出参数

  • 用返回值就好,但是别用const,因为它不但没有附加价值,而且还会干扰移动语义

要返回多个 出 值,优先考虑返回结构体或者多元组

  • 当你向std::set中插入一个值时,成员函数insert的重载会返回一个std::pair,它由两部分组成:

    • 一个指向所插入元素的迭代器
    • 还有一个bool,如果插入成功,它会被设置为true
  • C++11中的std::tie和C++17中的结构化绑定是将两个值绑定到某变量的两种优雅方式

  • 结构化绑定
    C++结构化绑定(Structured Bindings)是C++17引入的一项特性,它提供了一种方便的方式来将多个变量绑定到结构体、元组或其他类似的数据结构的成员上。结构化绑定的目的是简化对结构化数据的访问和处理。

在结构化绑定中,可以使用auto关键字和花括号来声明和初始化多个变量,这些变量会被绑定到结构体或元组的成员。这使得代码更加简洁和可读。

以下是结构化绑定的基本语法:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <tuple>

int main() {
// 示例:使用结构化绑定绑定元组的元素
std::tuple<int, double, std::string> myTuple{42, 3.14, "Hello"};

auto [a, b, c] = myTuple; // 结构化绑定

// 现在 a, b, c 分别是 myTuple 的元素
// a = 42, b = 3.14, c = "Hello"

return 0;
}

上述例子中,auto [a, b, c] 表示使用结构化绑定将myTuple中的元素绑定到变量abc上。

另外,结构化绑定还可以用于对结构体成员的绑定,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Point {
int x;
int y;
};

int main() {
Point p{10, 20};

auto [px, py] = p; // 结构化绑定

// px = 10, py = 20

return 0;
}

结构化绑定在循环中也非常有用,可以方便地遍历容器中的元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <vector>

int main() {
std::vector<std::pair<int, double>> vec;

for (const auto& [index, value] : vec) {
// 使用结构化绑定遍历容器中的元素
// index 和 value 分别绑定到 pair 的第一个和第二个元素
// ...
}

return 0;
}

结构化绑定在C++中提供了一种更简洁、更灵活的方式来处理结构化数据,从而使代码更加清晰易读。这个特性的引入使得C++语言更具现代感和表达力。

参数传递: 所有权语义

  • func(value): 函数func自己有一份value的拷贝并且就是其所有者。func会自动释放该资源

  • func(pointer*): func借用了资源,所以无权删除该资源。func在每次使用前都必须检查该指针是否为空指针

  • func(reference&):func借用了资源。与指针不同,引用的值总是合法的

  • func(std::unique_ptr): func是资源的新所有者。func的调用方法显式地把资源的所有权传递给了被调用方。func会自动释放该资源

  • func(std::shared_ptr): func是资源的额外所有者。func会延长资源的生存期。在func结束时,它也会结束对资源的所有权。如果func是资源的最后一个所有者,那么它的结束会导致资源的释放

  • 在应用层面使用std::move的意图并不在于移动。在应用层面使用std::move的目的是所有权的转移

  • C++ 所有权语义 详解
    C++的所有权语义指的是对于对象内存的所有权管理方式,即确定何时创建、拥有、传递和销毁对象。在C++中,主要有两种所有权语义,即值语义和引用语义。

  1. 值语义(Value Semantics)

    • 对象拥有其值:当使用值语义时,对象在栈上或作为成员变量直接存储其值。当对象复制时,新的对象独立拥有自己的值,不受原始对象的影响。
    • 拷贝构造函数和赋值运算符:值类型对象通常需要定义拷贝构造函数和赋值运算符,以确保正确地复制对象的值。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    class ValueSemanticsExample {
    public:
    ValueSemanticsExample(int val) : value(val) {}

    // 拷贝构造函数
    ValueSemanticsExample(const ValueSemanticsExample& other) : value(other.value) {}

    // 赋值运算符
    ValueSemanticsExample& operator=(const ValueSemanticsExample& other) {
    if (this != &other) {
    value = other.value;
    }
    return *this;
    }

    private:
    int value;
    };
  2. 引用语义(Reference Semantics)

    • 对象拥有引用:当使用引用语义时,对象本身并不存储值,而是存储对其他对象的引用。多个对象可以共享相同的值。
    • 拷贝构造函数和赋值运算符需小心:引用类型对象通常需要小心处理拷贝构造函数和赋值运算符,以避免意外地共享底层资源。
    1
    2
    3
    4
    5
    6
    7
    class ReferenceSemanticsExample {
    public:
    ReferenceSemanticsExample(int& valRef) : valueReference(valRef) {}

    private:
    int& valueReference;
    };

在现代C++中,智能指针也提供了一种更灵活的所有权语义。智能指针允许在堆上动态分配内存,并通过引用计数等机制来管理内存的释放。std::unique_ptr提供了独占所有权,而std::shared_ptr允许多个指针共享同一块内存。

1
2
3
4
5
6
7
8
9
#include <memory>

class SmartPointerExample {
public:
SmartPointerExample(int val) : value(std::make_unique<int>(val)) {}

private:
std::unique_ptr<int> value;
};

在选择值语义还是引用语义时,需要根据程序的需求和性能考虑。值语义通常更容易理解和使用,而引用语义可以更有效地共享资源,但需要小心管理共享状态,以避免潜在的问题。

返回 T* (仅仅)用于表示位置

  • 指针仅用于表示位置。这正是函数find的作用

  • 指针或引用永远不应该转移所有权

当不希望发生拷贝,也不需要表达 没有返回对象 时,应该返回 T&

  • 当不存在 没有返回对象 这种可能性的时候,就可以返回引用而非指针了

  • 有时你想进行链式操作,但是不详为不必要的临时对象进行拷贝和析构

  • 返回局部对象的引用(指针)是未定义行为。未定义行为本质上意味着,不要假想程序的行为

不要返回 T&&

不要返回 std::move(本地变量)

main()的返回类型是 int

  • 依照C++标准,main函数有两种变体

    • int main() {…}
    • int main(int argc, char** argv[]) {…}
  • 第二个版本等效于 int main(int argc, char* argv[]) {…}

  • main函数并不需要返回语句。如果控制流到达main函数的末尾而没有碰到一条返回语句,其效果相当于执行 return 0; 这意味着程序的成功执行

当函数不适用时(需要捕获局部变量,或者编写一个局部函数),请使用lambda表达式

  • 什么时候必须用lambda表达式,什么是否必须使用普通函数。这里有两条明显的理由
    • 如果可调用实体必须捕获局部变量或者它是在局部作用域内声明的,你就必须使用lambda函数
    • 如果可调用实体需要支持重载,那么应该使用普通函数

在局部使用(包括要传递给算法)的lambda表达式中,优先通过引用来捕获

在非局部使用(包括要被返回,存储在堆上或者要传递给其他线程)的lambda表达式中,避免通过引用来捕获

  • 这两条规则高度关联,它们可以归结为: lambda表达式应该只对有效数据进行操作。
    • 当lambda通过拷贝捕获数据时,根据定义,数据总是有效的
    • 当lambda通过引用捕获数据时,数据的生存期必须超过lambda的生存期

在有选择的情况下优先采用默认参数而非重载

  • 如果你需要用不同数量的参数来调用一个函数,尽可能优先采用默认参数而不是重载。这样你就遵循了DRY(不要重复自己)原则

不要使用 va_arg 参数

  • 当你的函数需要接受任意数量的参数时,要使用变参模板而不是va_arg参数

  • 变参数函数(variadic function)是像std::printf这样的函数,可以接受任意数量的参数。

  • 问题是,必须假设传递的类型是正确的。当然这种假设非常容易出错,其正确性依赖于程序员的素养

  • va_arg宏的背景信息

    • va_list : 保存下列宏的必要信息
    • va_start : 启用对变参数函数参数的访问
    • va_arg : 访问下一个变参数函数的参数
    • va_end : 结束对变参数函数参数的访问
  • C++ va_arg 变参数函数参数 详解
    va_arg 是C语言中用于处理可变参数函数(变参函数)的宏,它允许函数接受可变数量的参数。在C++中,虽然不鼓励使用C风格的可变参数函数,但仍然支持这一特性。

以下是关于va_arg的详解:

  1. 头文件
    使用 va_arg 需要包含 <cstdarg> 头文件,其中定义了一组宏和类型,用于处理可变参数函数。

    1
    #include <cstdarg>
  2. 可变参数函数的声明和定义
    可变参数函数通常以省略号 ... 结尾,其中包含不定数量的参数。va_list 类型用于存储参数列表。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    #include <cstdarg>
    #include <iostream>

    void myVarArgsFunction(int num, ...) {
    va_list args;
    va_start(args, num);

    for (int i = 0; i < num; ++i) {
    int value = va_arg(args, int);
    std::cout << "Argument " << i + 1 << ": " << value << std::endl;
    }

    va_end(args);
    }

    在上面的例子中,myVarArgsFunction 接受一个整数参数 num,表示后续可变参数的数量。函数通过 va_arg 从参数列表中获取具体的参数值。

  3. **va_startva_end**:
    在使用 va_arg 之前,需要使用 va_start 宏初始化 va_list 对象,以及在函数结束时使用 va_end 宏清理资源。

    1
    2
    3
    4
    5
    6
    7
    8
    void myVarArgsFunction(int num, ...) {
    va_list args;
    va_start(args, num);

    // ...

    va_end(args);
    }
  4. 注意事项

    • 参数传递的方式对使用 va_arg 很重要。对于整数、指针等基本类型,va_arg 可以很好地工作。但对于复杂的用户自定义类型,需要谨慎处理。
    • 没有可变参数的方式获取参数数量,需要依赖固定参数来传递数量信息。
    • 使用 va_arg 时,需要清楚每个参数的类型,以避免类型不匹配的问题。
  5. C++ 中的替代方案
    在现代C++中,推荐使用模板和标准库中的可变参数模板(variadic templates)来代替传统的可变参数函数。这种方式更类型安全,更灵活。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    #include <iostream>

    template <typename... Args>
    void myVarArgsFunction(Args... args) {
    (std::cout << ... << args) << std::endl;
    }

    int main() {
    myVarArgsFunction(1, "Hello", 3.14);
    return 0;
    }

    在上面的例子中,使用可变参数模板实现了类似的功能,同时获得了更好的类型安全性。

  • 这些问题可以通过C++17的折叠表达式轻松解决。跟va_arg相比,折叠表达式会自动推导出其参数的数量和类型
  • 变参模板可以接受任意数量的参数。这些任意数量的参数由所谓的参数包持有,用省略号表示。
  • 此外,在C++17中,可以用二元运算符直接对参数包进行归约。这一针对变参模板的增强被称为折叠表达式

本章精华

  • 一个函数应该执行一个操作,要简短,并有一个精心选择的名字
  • 要把可以在编译期运行的函数实现为constexpr
  • 如果可能的话,将你的函数实现为纯函数
  • 区分一个函数的入,入/出和出参。对入参使用按值传递或者按const引用传递,对入/出参数使用按引用传递,对出参使用按值传递
  • 向函数传递参数涉及所有权语义的问题。按值传递使函数成为资源的独立所有者。按指针或引用传递意味着函数值是借用了该资源。std::unique_ptr将所有权转移给函数,std::shared_ptr则使函数称为共享的所有者
  • 当你的函数需要接受任意数量的参数时,要使用变参模板而不是va_arg参数

第五章 类和类层次结构

  • 类是一种用户定义类型,程序员可以为其指定表示方法,操作和接口。

  • 类的层次结构被用来组织相关的结构

  • Guidelines先给出了一些概要规则:

    • 具体类型
    • 构造函数,赋值和析构函数
    • 类的层次结构
    • 重载和运算符重载
    • 联合体

class(类)和struct(结构体)之间的语法差异

  • 在结构体中,所有成员默认为 public(公开)。在类中,所有成员默认 private(私有)
  • 继承的情况也是如此。结构体的基类默认为 public,类的基类默认为 private

把相关的数据组织到结构(struct或class)中

  • 通过将相关元素放在结构体中,函数签名变得可以自我描述。

当类具有不变式时使用class;如果数据成员可以独立变化,则使用struct

  • 类的不变式是用于约束类的实例的不变式。成员函数必须使这个不变式保持成立。不变式约束了类的实例的可能取值。

  • C++ 类中的不变式是什么意思

    • 在C++中,类的不变式(invariant)指的是在类的对象上始终保持成立的条件或属性。这是一种约定或规则,用于确保对象的有效性和一致性。在面向对象编程中,不变式是类设计的一部分,用于描述对象应该具有的状态。
    • 不变式通常与类的公共接口和方法一起工作,确保在对象上执行操作时,类的内部状态不会违反这些不变式。不变式可以看作是类内部约定的一部分,旨在维护对象的有效性。
    • 例如,考虑一个表示时间的类,该类有小时和分钟两个成员变量。一个可能的不变式是,小时应该在0到23之间,分钟应该在0到59之间。在类的方法中,如果有任何操作可能破坏这个不变式,需要在方法执行前或执行后重新确保不变式的成立。
  • 类的不变式在构造函数中被初始化和检查

在类中体现出接口和实现之间的区别

  • 类的公开成员函数是类的接口,私有部分则是实现

仅当函数需要直接访问类的内部表示时,才把它变成成员

  • 如果一个函数不需要访问类的内部结构,它就不应该是成员。这样的话,你会得到松耦合,而类的内部结构的改变不会影响辅助函数

  • 运算符 =, (), [] 和 -> 必须是类的成员

将辅助函数与它们支持的类放在同一个命名空间中

  • 辅助函数应该在类的命名空间中,因为它是类的接口的一部分。与成员函数相反,辅助函数不需要直接访问类的内部表示。

不要在一条语句里定义类或者枚举的同时声明该类型的变量

  • 如果在一条语句里定义类或者枚举同时声明其类型的变量,会引起混淆,因此应该避免

如有任何非公开成员,就使用class,而不是struct

尽量减少成员的暴露

  • 数据隐藏和封装是面向对象类设计的基石之一:你将类中的成员封装起来,只允许通过公共成员函数进行访问。
  • 你的类可能有两种接口
    • 一种是用于外部的 public 接口
    • 一种是用于派生类的 protected接口。
  • 其余成员都应该属于 private

具体类型

  • 具体类型是 最简单的一种类。它常常被称作为值类型,不属于某个类型层次结构的一部分
  • 规范类型是一种 行为类似于int 的类型,因此,它必须支持拷贝和赋值,相等比较,以及可交换。更正式的说法是,一个规范类型X行为上像int,支持下列操作
    • 默认构造: X()
    • 拷贝构造: X(const X&)
    • 拷贝赋值: operator = (const X&)
    • 移动构造: X(X&&)
    • 移动赋值: operator = (X&&)
    • 析构: ~X()
    • 交换操作: swap(X&, X&)
    • 相等运算符: operator == (const X&, const X&)

优先使用具体类型而不是类层次结构

  • 如果没有需要类层次结构的用例,就使用具体类型。具体的类型更容易实现,更小且更快。
  • 不必担心继承,虚性,引用或指针,包括内存分配和释放。不会有虚派发,因此也没有运行期的开销

让具体类型规范化

  • 如果你有一个具体类型,可以考虑将它升级为规范类型
  • 内置类型(例如int或者double)是规范类型,而用户定义类型(例如std::string)或容器(std::vector, std::unordered_map)也是如此

构造函数 ,赋值函数和析构函数

  • 六个特殊的成员函数,它们控制着对象的生命周期

    • 默认构造函数: X()
    • 拷贝构造函数: X(const X&)
    • 拷贝赋值函数: operator = (const X&)
    • 移动构造函数: X(X&&)
    • 移动赋值函数: operator = (X&&)
    • 析构函数: ~X()
  • 编译期可以为这 六大 生成默认实现,但是也可以明确用 =default(预置) 来要求编译提供它们,或者使用 =delete(预置) 来删除它们

  • 默认构造函数可以在没有参数的情况下被调用,但是它可能每个参数都有默认值

如果能避免定义默认操作,那就这么做

  • 这一规则也被称为 零法则。这意味着你可以通过使用有合适的拷贝/移动语义类型,来避免自行编写构造函数,拷贝/移动构造函数,赋值运算符或者析构函数。

  • 有合适的拷贝/移动语义的类型包括规范类型,例如内置类型bool或者double,也包括标准模板库(STL)的容器,例如std::vector或者std::string

  • 当编译器为一个类自动生成拷贝构造函数时,它调用该类的所有成员和所有基类的拷贝构造函数

如果定义或 =delete 了任何默认操作,就对所有默认操作都进行定义或 =delete

  • 六大 是紧密相关的。由于这种关系,你应该对所有特殊成员函数进行定义或者 =delete

让默认操作保持一致

构造函数应当创建完全初始化的对象

  • 构造函数的职责就是创建完全初始化的对象。类不应该有init(初始化)成员函数。将成员函数init设为私有,并从所有构造函数中调用它,这样做好一些,但是仍然不是最佳选择。

  • 当一个类的所有构造函数有共同的操作时,请使用委托构造函数.

  • C++ 委托构造函数是什么
    C++11引入了委托构造函数的概念,它允许一个构造函数调用同一类中的另一个构造函数,以便避免代码的重复。

具体来说,委托构造函数是通过在成员初始化列表中使用自身类的其他构造函数来实现的。这样可以在一个构造函数中调用另一个构造函数,从而避免重复初始化相同的代码。

以下是一个简单的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <iostream>

class MyClass {
public:
// 委托构造函数
MyClass(int x) : MyClass(x, 0) {}

// 主要构造函数
MyClass(int x, int y) : x(x), y(y) {
std::cout << "Constructing MyClass(" << x << ", " << y << ")" << std::endl;
}

void print() {
std::cout << "x: " << x << ", y: " << y << std::endl;
}

private:
int x;
int y;
};

int main() {
MyClass obj1(42);
obj1.print();

MyClass obj2(10, 20);
obj2.print();

return 0;
}

在上面的示例中,MyClass有两个构造函数,其中一个是主要构造函数,另一个是委托构造函数。委托构造函数通过调用主要构造函数来初始化对象。

通过委托构造函数,可以在不同的构造函数中共享初始化逻辑,提高代码的可维护性。需要注意的是,委托构造函数的调用必须在成员初始化列表中完成,而不能在构造函数的函数体内。

如果构造函数无法构造出有效对象,则应该抛出异常

  • 如果使用无效的对象,你就总得在使用之前检查对象的状态.这样非常繁琐,低效而且容易出错.

确保可拷贝的(值类型)类有默认构造函数

  • 不正式的说,当类的实例缺少有意义的默认值时,该类就不需要默认构造函数

不要定义仅初始化数据成员的默认构造函数,而应该使用成员初始化器

  • 在设计新类时遵循的方法是:在类的主体中定义默认行为。明确定义的构造函数只用来改变默认行为

默认情况下,把单参数的构造函数声明为explicit

  • 说的更明确一点,一个没有explicit的单参数构造函数是个转换构造函数。

按照成员声明的顺序定义和初始化成员变量

  • 类成员是按照它们的声明顺序进行初始化的
  • 类的成员完全按照它们初始化的相反顺序被销毁

在使用常量来初始化时,优先选择类内初始化器,而不是构造函数的成员初始化

  • 虽然类内初始化规定了一个对象的默认行为,但是构造函数可以改变这一默认行为。

在构造函数里优先使用初始化而不是赋值

  • 初始化对赋值有两个最明显的优点:
    • 你不会因为忘记赋值而使用未初始化的成员
    • 初始化可能更快,并且绝不会比赋值慢

特殊构造函数

  • 从C++11开始,一个构造函数可把它的工作委托给同一个类的另一个构造函数,并且构造函数可从父类继承。

使用委托构造函数来表示类的所有构造函数的共同动作

  • 一个构造函数可以把它的工作委托给同一个类的另一个构造函数。委托是C++中把所有构造函数的共同动作放到一个构造函数中的现代方式。
  • 递归调用构造函数是未定义行为。

使用继承构造函数将构造函数导入不需要进一步显式初始化的派生类中

  • 如果可以的话,在派生类中重用基类的构造函数。当派生类没有成员时,这种重用的想法很合适。
  • 如果在可重用构造函数时不重用,就违反了DRY(不要重复自己)原则。
  • 继承的构造函数保留了它们在基类中定义的所有特性,例如访问说明符或者属性explicit和constexpr

拷贝操作

  • 在拷贝之后(a = b),a 和 b必须相同(a == b)
  • 拷贝可深可浅。
    • 深拷贝意味着对象a和b之后时相互独立的(值语义)
    • 浅拷贝意味着对象a和b之后共享一个对象(引用语义)

移动操作

  • C++标准要求被移动的对象之后必须处于一个未指定但是有效的状态。通常情况下,这个被移动的状态是移动操作源对象的默认状态

多态类应当抑制公开的拷贝/移动操作

  • 多态类是定义或者继承了至少一个虚函数的类
  • 拷贝一个多态类的操作可能会以切片而告终。切片是C++中最黑暗的部分之一
    • 切片意味着你想在赋值或者初始化过程中拷贝一个对象,但是你只得到该对象的一部分。

析构函数

  • 对象的析构函数会在其生存期结束时被自动调用。更准确地说,对象的析构函数是在对象超出作用域时调用的

如果一个类在对象销毁时需要明确的动作,那就定义析构函数

  • 问题在于,在你的情况下,编译器生成的析构函数是否已经够用。
    • 如果必须在用户定义类型的生存期结束时执行额外的代码,就必须写析构函数。
    • 反过来说,如果类中没有成员需要额外的清理,就没必要定义析构函数

类获得的所有资源都必须在该类的析构函数中释放

指针和引用

  • 如果你的类有原始指针或者引用,则必须要回答一个关键问题: 谁是所有者

如果类里有原始指针(T*)或者引用(&),请考虑它是否有所有权

  • 如果一个类有原始指针或者引用,你必须明确所有权问题,因为指针既可以表示所有权,也可以表示借用。
  • 如果所有权不明确,你可能会删除你不拥有的一个对象的指针,也可能会漏删你拥有的一个指针。在第一种情况下,由于双重删除,会有未定义行为;在第二种情况下,会面临内存泄漏

如果类具有所有权的指针成员,请定义析构函数

  • 如果类拥有一个对象,它就要负责销毁它,销毁是析构函数的工作。

基类的析构函数应该要么是public且virtual,要么是protected且非virtual

  • 公开的虚析构函数

    • 如果基类有public且virtual的析构函数,你可以通过基类的指针来销毁派生类的实例,引用也是如此。
  • 受保护的非虚析构函数

    • 如果基类的析构函数是protected,你就不能用基类的指针或者引用来销毁派生对象;因此,析构函数不需要声明为virtual
  • 关于基类的析构函数的访问说明符的一些总结性意见

    • 如果基类的析构函数私有(private),你就无法从该类派生
    • 如果基类的析构函数受保护(protected),那么你能从该类派生出子类,然而只能使用子类

如果你需要明确使用默认语义,则使用 =default

当想要禁用默认行为(且不需要替代方法)时使用 =delete

不要在构造函数和析构函数中调用虚函数

  • 在构造函数或析构函数中调用纯虚函数是未定义行为。

使 == 对操作数的类型对称,并使其 noexcept

  • 解决不对称的优雅方法是在类中声明一个友元运算符 ==
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class MyInt
    {
    int num;
    public:
    MyInt(int n) : num(n) {};
    friend bool operator == (const MyInt& lhs, const MyInt& rhs) noexcept
    {
    return lhs.num == rhs.num;
    }
    };

类层次结构

  • 什么是类的层次结构?

    • 一个类的层次结构代表了一组分层组织的概念。
    • 基类通常有两种用途。一种通常被称为接口继承,另一种是实现继承。
  • 接口继承使用公共继承,它把用户和实现分开,允许派生类增加或者改变基类的功能,而不影响基类的用户。

  • 实现继承经常使用私有继承。在典型情况下,派生类通过调整基类的功能来提供其功能。实现继承的一个突出例子是适配器模式,因为你可以用多重继承来实现。适配器模式的想法是将现有的接口改编成一个新的接口。适配器使用了对实现的四有继承和对新接口的公共继承。新的接口使用现有的实现来为用户提供服务。

(仅)使用类的层次结构来表达具有内在层次结构的概念

如果基类被当作接口使用,那就把它变成抽象类

  • 抽象类是至少有一个纯虚函数的类。纯虚函数(virtual void function() = 0)是必须由派生类实现的函数(除非派生类也是抽象类)。一个抽象类不能被实例化。
  • 抽象类可以为纯虚函数提供一个实现。这样,派生类也可以使用这个实现。
  • 接口通常应该由公共的纯虚函数组成,没有数据成员,并且有默认/空的虚析构函数(virtual ~My_interface() = default)

当需要完全分离接口和实现时,以抽象类作为接口

  • 抽象类的目的就是分离接口和实现。

如果要对多态类进行深拷贝,应该使用虚函数clone,而不是公开的拷贝构造/赋值

  • 拷贝一个多态类可能会导致切片问题。为了解决这个问题,应该覆盖一个虚clone函数,让它根据实际类型进行复制并且返回一个到新对象的有所有权的指针(std::unique_ptr)

  • 在派生类里,通过使用所谓的协变返回类型来返回派生类型

    • 协变返回类型 : 允许覆盖成员函数返回被覆盖成员函数的返回类型的派生类型。
  • C++ 协变返回类型是什么
    C++11引入了协变返回类型(Covariant Return Types)的概念,允许在派生类中覆盖基类的虚函数时,返回类型可以是基类函数返回类型的派生类型。

在使用协变返回类型时,你可以在派生类中重新定义虚函数的返回类型,而不必显式使用类型转换。这使得代码更加清晰和类型安全。

以下是一个简单的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <iostream>

class Base {
public:
virtual Base* clone() const {
std::cout << "Base::clone()" << std::endl;
return new Base(*this);
}
};

class Derived : public Base {
public:
// 协变返回类型
virtual Derived* clone() const override {
std::cout << "Derived::clone()" << std::endl;
return new Derived(*this);
}
};

int main() {
Derived derived;
Base* basePtr = &derived;

// 调用协变返回类型的虚函数
Base* cloned = basePtr->clone();

delete cloned;

return 0;
}

在上述示例中,Derived 类覆盖了基类 Base 的虚函数 clone,并使用协变返回类型,返回的类型是 Derived* 而不是 Base*。这样,通过基类指针调用虚函数时,返回的是正确的派生类型。

不要无缘无故的把函数变成 virtual

  • 虚函数不是一个无代价的特性

    • 增加了运行时间和对象代码的大小
    • 因为它可以在派生类中被覆盖,它更容易出问题
  • 通常情况下,一个类的所有数据成员的访问说明符都相同:所有数据成员要么全部属于public,要么全部属于private

    • 如果数据成员上面没有不变式,则用public。使用struct关键字
    • 如果数据成员上有不变式,则用private。使用class关键字。

避免无价值的取值和设值函数

  • 如果取值和设值函数没有对数据成员提供额外的语义,它们就没有价值
  • x和y可以取任意值。这意味着Point的实例并没有对x和y维持一个不变式。x和y仅仅是数值而以,更合适的做法是以struct作为值的集合。

避免protected数据

  • protected数据使程序变得复杂且容易出错。如果把protected数据放到基类里,你就不能单独考虑派生类,因而破坏了封装。你会不得不对类的整个层次结构进行思考

    • 我必须实现一个构造函数来初始化protected数据吗
    • 如果我使用protected数据,它们的实际价值是什么
    • 如果我修改protected数据,谁会受影响
  • 在类层次结构变得越来越复杂的时候,这些问题也会变得越来越难以回答

  • 换句话说,protected数据都是类层次结构范围内的一种全局数据而你知道非const的全局数据不好。

确保所有的非const数据成员都具有相同的访问级别

多重继承

  • 多重继承有两个典型的使用场景:
    • 将接口继承和实现继承分开
    • 实现多个不同的接口

在设计类的层次结构时,要区分实现继承和接口继承

  • 接口继承关注的是接口和实现的分离,这样派生类的修改就可以不影响基类的用户;

  • 实现继承则使用继承来扩展现有的功能,从而支持新功能。

  • 纯接口继承是指你的基类只有纯虚函数。相反,如果你的基类有数据成员,或者已经有函数实现,那就是实现继承了。

  • 不应该把接口继承和实现继承的概念混在一起。我们如何才能两者兼顾:用接口分层的稳定接口,还有实现继承的代码重用?

    • 一个可能的答案是双重继承。
    • 另一个答案是PImpl惯用法。
  • PImpl代表指向实现的指针(pointer to implementation)。它把实现的细节放在一个单独的类里,并通过指针来访问。

使用多重继承来表示多个不同的接口

使用using为派生类及其基类创建重载集

不要为虚函数和它的覆盖函数提供不同的默认参数

  • 如果你为虚函数和覆盖函数提供不同的默认参数。你的类可能会引发很多混乱

dynamic_cast

  • dynamic_cast的职责说明:
    • 沿继承层及向上,向下及侧向,安全的转换到其他类的指针和引用

在穿越类层次不可避免时,应该使用 dynamic_cast

  • dynamic_cast的职责就是在类层次中穿越

当 找不到所需的类 被视为错误时,须对引用类型使用 dynamic_cast

当 找不到所需的类 被视为有效选择时,须对指针类型使用 dynamic_cast

  • 简而言之,你可以对一个指针或引用使用 dynamic_cast。
  • 如果 dynamic_cast 失败了,对于指针,你会得到一个空指针,对于引用,则会出现 std::bad_cast 异常。
  • 因此,如果失败是一种有效选择,请对指针使用 dynamic_cast;如果失败不是一个有效选择,那就是用使用

永远不要把指向派生类对象数组的指针赋值给指向基类的指针

  • 其结果可能是无效对象访问或者内存破坏

  • 退化,是一种隐式转换的名称,它进行左值到右值,数组到指针以及函数到指针的转换,并去除const和volatile限定。

重载和运算符重载

  • 你可以对函数,成员函数,模板函数和运算符进行重载。你不能重载函数对象,因此你也不能重载lambda表达式。
  • 重载和重载运算符的七条规则遵循一个关键思想:为用户构建直观的软件系统。

应当对带有常规含义的操作使用运算符

  • 常规意义暗示着,你应当使用合适的运算符。举例来说:
    • ==, !=, <, <=, >, >= : 比较操作
    • +, -, *, /, % : 算术操作
    • ->, 一元*, [] : 对象访问
    • = : 对象赋值
    • <<, >> : 输入和输出操作

对于对称的运算符,应采用非成员函数

避免隐式转换运算符

重载的操作应当大致等价

仅对大致等价的操作进行重载

  • 等价的操作应该有相同的名字。

在操作数所在的命名空间中定义重载运算符

  • 实参依赖查找(ADL,也叫Koenig查找)意味着,对于无限定的(unqualified)函数调用,C++在编译时会把函数参数命令空间中的函数也考虑进去。
  • 对于运算符,C++编译时也考虑操作数的命名空间。因此,应该在操作数的命名空间中定义重载运算符。

联合体

  • 联合体是一种特殊的类类型。所有成员都从同一地址开始。一个联合体一次只能容纳一个类型:因此你可以节约内存。
  • 一个带标签联合体(又称可辨识联合体)是一个可以跟踪其类型的联合体。std::variant就是一个带标签联合体。
  • C++ Core Guidelines指出,联合体的职责是节约内存。你不应该使用裸联合体,而应该使用std::variant这样的带标签联合体。

使用union来节约内存

避免裸联合体

  • 裸联合体非常容易出错,因为你必须跟踪底层类型。

使用匿名union来实现带标签联合体

本章精华

  • 尽量使用具体类型而不是类的层次结构。让你的具体类型称为规范类型。规范类型支持六大(默认构造函数,析构函数,拷贝和移动构造函数,拷贝和移动赋值运算符),交换函数和相等运算符。
  • 如果可能的话,就让编译器生成这六大。如果不能,就通过default请求所有这些特殊成员函数。如果这也不行,就明确实现所有这些函数,并给它们一个一致的设计。拷贝构造函数或拷贝赋值运算符应该拷贝构造。移动构造函数或移动赋值运算符应该移动。
  • 构造函数应该返回一个完全初始化的对象。使用构造函数来建立不变式。(不变式(invariant),是一个在程序执行过程中永远保持成立的条件。不变式在检测程序是否正确方面非常有用。例如编译器优化就用到了不变式)。不要使用构造函数来设置成员的默认值。尽量使用类内初始化来减少重复。
  • 如果你需要在对象销毁时进行清理动作,请实现析构函数。基类的析构函数应该要么是public且virtual,要么是protected且不是虚函数
  • 要对具有内在层次的结构使用类的层次结构进行建模。如果基类作为一个接口使用,就让基类成为抽象类,以便分离接口和实现。一个抽象类应该只有一个预置的默认构造函数
  • 区分接口继承和实现继承。接口继承的目的是将用户与实现分隔开:实现继承是为了重用现有的实现。不要在一个类中混合这两个概念。
  • 在一个有虚函数的类里,析构函数应该要么是public加virtual,要么是protected。对于一个虚函数,要使用virtual override或者final中的一个,不多也不少。
  • 一个类的数据成员应该要么全部public,要么全部private。如果类建立了一个不变式,就让它们全部private,并使用class。如果不是,就让他们public,并使用struct
  • 把单参数构造函数和转化运算符标记为explicit
  • 使用联合体来节约内存,但不要使用裸联合体;尽量使用带标签联合体,例如C++17中的std::variant

第六章 枚举

  • 枚举用来定义整数值的集合,也是这类集合的类型
  • 优先选择有作用域枚举(scopted enumeration),而不是传统的枚举。有作用域枚举也被称为强类型枚举或枚举类(enum class)
  • 传统的枚举有什么缺陷?
    • 没有作用域
    • 会隐式转换为int
    • 会污染全局命令空间
    • 类型未知。只要求该类型足够大且能容下所有枚举向

优先使用枚举而不是宏

  • 宏没有作用域,且没有类型

使用枚举表示相关联的具名常量的集合

优先使用 enum class,而不是普通enum

  • 有作用域枚举项(enum class)不会自动转型为int。要访问它们,就必须要使用作用域运算符

不要对枚举项使用ALL_CAPS命令方式

  • 如果对枚举项使用ALL_CAPS(全大写加下划线),你可能会与宏发生冲突,因为宏通常写成ALL_CAPS的方式
  • 当然,这条规则不仅适用于枚举项,也适用于一般常量

避免使用无名枚举

  • 不是每个编译期常量都应该是enum。C++还允许将编译期常量定义为constexpr变量。只是互相关联的常量集合中使用enum

仅在必要时指定枚举的底层类型

  • 从C++11开始,可以指定枚举的底层类型,以节省内存。默认情况下,有作用域enum的类型是int,因此,可以前置声明enum

仅在必要时指定枚举项的值

  • 通过指定枚举项的值,你可能会设定一个值两次

本章精华

  • 使用有作用域枚举而不是传统的枚举。顾名思义,有作用域枚举具有作用域,不会隐式地转换为int,不会污染全局命名空间,默认情况下其底层类型是int
  • 仅在必要时指定有作用域枚举的底层类型和枚举项的值

第七章 资源管理

  • 首先,资源是什么?资源就是你必须管理的东西。这意味着,你因为资源的有限而必须获取和释放它,或者你必须对它进行保护。

  • 你拥有的存储空间,套接字,进程或线程都是有限的;而在某个时间点上,只有一个进程可以写入共享文件,只有一个线程可以写入共享变量。

  • 如果考虑到资源管理,一切都可以归结为一个关键点: 所有权。现代C++非常优秀的一点是,我们可以在代码中直接表达对所有权的意图。

    • 局部对象: C++运行时作为所有者来自动管理这些资源的生存期。全局对象或类的成员也是如此。C++ Core Guidelines中将它们称为有作用域的对象。
    • 引用: 我不是所有者。我仅仅借用了不可以为空的资源
    • 原始指针: 我不是所有者。我仅仅借用了可能为空的资源。我不可以删除该资源。
    • std::unique_ptr: 我是资源的独占所有者。我可以显式的释放资源。
    • std::shared_ptr: 我跟其他的shared_ptr共享资源,并且当我是最后一名所有者时会释放资源。我可以显式地释放我的所有权份额
    • std::weak_ptr: 我不是该资源的所有者,但是我可以通过使用成员函数lock()暂时称为该资源的共享所有者。
  • 第一条通用规则是 C++惯用法: RAII。RAII代表Resource Acquisition Is Initialization(资源获取即初始化)。C++标准库系统性地依赖于RAII

使用资源句柄和RAII(资源获取即初始化)自动管理资源

  • RAII的理念很简单,你为资源创建一种代理对象。代理的构造函数获取资源,而代理的析构函数释放资源。RAII的中心思想是,这个代理作为局部对象,其所有者是C++运行时,于是,它所代理的资源也归C++运行时所有。当作为本地对象的嗲里对象离开作用域的时候,代理的析构函数会被自动调用。
  • RAII在C++生态系统中被大量使用。RAII的例子有标准模板库(STL)的容器,智能指针和锁。
    • 容器管理元素
    • 智能指针管理内存
    • 锁管理互斥量

原始指针(T*)不表示所有权

原始引用(T&)不表示所有权

  • 这两条规则都概括了向函数传递指针或引用时,以及要从函数返回指针(T*)或者左值引用(T&)时所有权方面需要考量的规则。
  • 指针和引用的关键问题是,谁是资源的所有者?
    • 如果你不是所有者,只是借用了它,那你不得删除该资源

优先使用有作用域的对象,不做非必要的堆上分配

  • 有作用于的对象是一个带有自己作用域的对象。它可能是个本地对象,全局对象或者某个类的成员。

  • C++运行时会管理好有作用域的对象

  • 一种好用的技巧是使用额外的花括号来定义一个人工作用域。由于人工作用域的存在,你可以显式地控制一个本地对象的生存期。

内存分配和释放

  • 在C++中使用new创建一个对象的操作包括两个步骤
    • 为该对象分配内存
    • 在分配好的内存上构造该对象
  • operator new 或 operator new []是第一步;构造函数是第二步
  • 同样的策略也适用于析构函数,不过得反过来。
    • 首先,调用析构函数(如果有的话),
    • 然后用operator delete或者operator delete []释放内存

避免malloc() 和 free()

  • new 和 malloc,或 delete 和 free 之间有什么区别?
    • C 函数malloc 和 free只做了一半的工作。malloc 分配内存,free则释放内存
    • malloc并不调用构造函数,而free也不调用析构函数
  • 这意味着,你如果使用一个仅仅通过malloc创建的对象,程序有未定义行为

避免显式调用 new 和 delete

  • 你应该牢记这条规则。这条规则的重点在于显式这个词,因为智能指针或STL容器会让你的对象隐式地使用 new 和 delete

立即将显式资源分配的结果交给一个管理者对象

  • 这条内存分配规则有一个特殊的名字: NNN.NNN是 No Naked New(不要裸的New)的缩写,它意味着内存分配的结果应该交给一个管理者对象。这个管理者对象可以是std::unique_ptr或者std::shared_ptr

在一条表达式语句中最多进行一次显式资源分配

智能指针

  • 从库的角度,智能指针是C++11标准中最重要的补充。
  • 智能指针的规则归结为两类
    • 作为所有者的基本用法
    • 以及作为函数参数的基本用法

用unique_ptr或shared_ptr表示所有权

  • 为了全面,这条规则还包括std::weak_pte。现代C++共有三种智能指针来表达三种不同的所有权

    • std::unique_ptr: 独占所有者
    • std::shared_ptr: 共享所有者
    • std::weak_ptr: 对std::shared所管理资源的非占有的引用
  • std::unique_ptr是其资源的独占所有者。它不可以被拷贝,只能被移动

  • std::shared_ptr则共享所有权。当你拷贝或者拷贝赋值某个共享指针,它的引用计数增加;当你删除或者重置某个共享指针,它的引用计数则减少。当引用计数变为0时,其底层资源将被删除。

  • std::weak_ptr并不是智能指针。它是一个引用,引用指向被std::shared_ptr所管理的对象。它的接口颇为有限,不可以透明地访问底层资源。通过对std::weak_ptr调用其成员函数lock,可以从某个std::weak_ptr创建出一个std::shared_ptr

除非需要共享所有权,否则能用unique_ptr就别用std::shared_ptr

  • 当你需要智能指针的时候,应该首选std::unique_ptr。在设计上std::unique_ptr和原始指针一样块,且一样可以高效利用内存。
  • 不要为了做拷贝而贪图方便地使用std::shared_ptr。std::unique_ptr不可以被拷贝,但仍可以被移动

使用make_shared()创建shared_ptr

使用make_unique()创建unique_ptr

  • 使用这种方式创建智能指针,有两个理由
    • 第一个理由是异常安全
    • 第二个理由只对std::shared_ptr成立
      • 当你调用std::shared_ptr(new int(1998))时,会发生两次内存分配:一次时针对new int(1998),还有一次是针对std::shared_ptr的控制块。内存分配代价较高,所以你应当尽量避免。
      • std::make_shared(1998)可将两次内存分配变成一次,因此更快。
      • 此外,分配出来的对象(new int(1998))和控制块彼此相邻,所以访问也会更快。

使用std::weak_ptr来打破shared_ptr形成的环

  • 如果std::shared_ptr互相引用,就会形成环状引用
  • 例如,一个双向连表就会形成环。如果使用std::shared_ptr来实现链表,那引用计数永远不会为零,最终导致内存泄漏

函数参数

  • 本节其余规则回答了这样几个问题

    • 函数该如何接受智能指针作为参数
    • 参数应该是std::unique_ptr还是std::shared_ptr
    • 参数应该以const还是以引用的方式获取
  • 你应该将这些以智能指针作为函数参数的规则看作之前更一般函数参数传递规则的细化

  • 首先,必须回答何时以智能指针作为函数参数

  • 其次,如果函数通过引用来获取参数,会带来危险

只在显式表达生存期语义时以智能指针作参数

  • 如果你把指针指针作为参数传递给一个函数,而在这个函数中,你只使用智能指针的底层资源,你就做错了。

  • 在这种情况下,应该以原始指针或引用作为函数参数,因为你并不需要智能指针的生存期语义

  • 重装(reset)表示函数会修改引用参数的智能指针的内容,来指向一个不同的对象。

  • 关于类型为std::shared_ptr的参数,有三条规则

    • 接受shared_ptr参数以表达函数是共享的所有者
    • 接受shared_ptr&参数以表达函数可能会重装共享指针
    • 接受const shared_ptr&参数以表达函数可能保有指向对象的一份引用计数

不要传递从智能指针别名中获得的指针或引用

  • 智能指针的别名(智能指针的引用)是智能指针,但你不是所有者。如果违反这条规则,会造成悬空指针
  • 更为直白的规则:只有当你实际持有共享资源所有权的一部分时,你才可以访问该资源

本章精华

  • 要自动管理资源。为资源创建某种代理对象。代理的构造函数获取资源,而代理的析构函数释放资源。C++运行时会管理好代理
  • 尽可能使用有作用域的对象。有作用域的对象是一个带有自己作用域的对象。它可能是个本地对象,全局对象,或者是某个类的成员。C++运行时会管理好有作用域的对象。
  • 不要使用malloc和free,并避免使用new和delete。立即将显式资源分配的结果交给一个管理者对象,例如std::unique_ptr或std::shared_ptr
  • 使用智能指针std::unique_ptr来表达独占所有权,并使用智能指针std::shared_ptr来表达共享所有权。使用std::make_unique来创建std::unique_ptr,并使用std::make_shared来创建std::shared_ptr
  • 如果你要表达生存期语义,就以智能指针作为函数参数。否则,用普通指针或引用即可
  • 按值接受智能指针作为函数参数以表达所有权语义;按引用接受智能指针以表达函数可能会重装智能指针。

第八章 表达式和语句

  • 表达式和语句是表达动作和计算的最基本和最直接的方式

  • 表达式和语句的非正式定义

    • 表达式的计算结果为值
    • 语句做某事,通常由表达式或语句组成
  • 包含在块作用域中的声明是语句。块作用域是指包含在花括号内的内容

优先使用标准库,而不是其他库和 手工代码

  • 如果你想提高组织中的代码质量,那就用一个目标取代所有的编码规则:用算法代替原始循环。

优先使用合适的抽象,而非直接使用语言特性

  • 合适的抽象通常意味着不必考虑资源的所有权

声明

  • 首先,以下是C++ Core Guidelines对声明的定义

    • 声明是一条语句。声明将名字引入作用域中,并可能引起具名对象的构造。
  • 声明的规则是关于名字,变量及其初始化以及宏的规则

  • 好名字可能是号软件最重要的规则

保持作用域较小

  • 如果作用域很小,你可以把它放在一屏之中以便弄清楚状况。如果作用域变得太大,则应该将代码结构化为函数或类。在重构过程中,要时别逻辑实体并使用自解释的名称。

在 for 语句的初始化和条件中声明名字以限制作用域

  • 自第一个C++标准开始,就可以在for语句中声明变量
  • 自C++17以来,我们还可以在if或switch语句中声明变量

常用的和局部的名字要短,不常用的和非局部的名字要长

  • 给变量起名i或j,或给变量起名T,其意图立即明确

    • i和j是索引
    • T是模板参数的类型
  • 名字应该是自解释的。在简短的上下文中,你一眼就能够理解变量的含义。但它不会自动适用于较长的上下文,这时,应该使用更长的名字

避免看起来相似的名字

避免ALL_CAPS风格的名字

  • 如果使用了ALL_CAPS(全大写加下划线)风格的名字,宏替换可能会发生,因为ALL_CAPS常常用于宏

每条声明(仅)声明一个名字

使用auto来避免类型名字的多余重复

  • 如果你使用auto,修改代码可能就是小菜一碟

不要在嵌套作用域中复用名字

  • 出于可读性和可维护性原因,不应在嵌套作用域中复用名字。

始终初始化对象

  • 使用auto,这样,你再也不会忘记初始化变量。

不要在确实需要使用变量(或常量)之前就将其引入

在获得可用来初始化变量的值之前不要声明变量。

优先使用{}初始化语法

  • 使用 {} 初始化的原因有很多

    • 始终可用
    • 克服最令人烦恼的解析
    • 放置窄化转换
  • 前两点使C++更符合直觉,而最后一点经常可以防止未定义行为。

  • 利用auto进行类型推导

    • 如果你用auto进行自动类型推导,然后结合使用{}初始化,你会得到std::initializer_list。
    • 这种违反直觉的行为在C++17中改掉了
  • 窄化转换是指算术值的隐式转换,精度损失也包含在内。

不要将同一个变量用于两个不相关的目的

使用lambda表达式进行复杂的初始化(尤其是对const变量)

  • 为什么要就地(in place)调用lambda函数?

    • 这条规则回答了这个问题。可以把复杂的初始化步骤放在lambda里。
    • 如果你的变量应该变成const,那么lambda的就地调用尤其有价值
  • 如果不想在初始化后修改你的变量,则应该让他成为const。但有时,变量的初始化包括不止一个步骤,因此,你不能使变量称为const

  • 这个时候,lambda表达式就可以用来救场了。你可以使用一种技术,它被称为立即调用的lambda表达式。

    • 你可以把初始化代码放到lambda表达式中,通过引用捕获环境变量,用就地调用的lambda函数初始化你的const变量。
      1
      2
      3
      4
      5
      6
      7
      8
      const widget x = [&]{
      widget val;
      for (auto i = 2; i <= N; ++i>)
      {
      val += some_obj.do_something_with();
      }
      return val;
      }();
    • 你需要把整个初始化代码都放在lambda的函数体中,最后一对圆括号调用了它。

  • 如果C++标准化委员会中有一个共识,那就是宏必须被淘汰。

避免复杂的表达式

如果对运算符优先级不确定,那就使用括号

保持指针的使用简单明了

  • 对指针的复杂操纵是错误的一个主要来源

避免 魔法常量,采用符号常量

  • 符号常量比魔法常量更明确

避免对范围检查的需要

  • 如果不需要检查范围的长度,你就不会遇到 差一错误(off-by-one error)
    • 一种常见的安全编码错误,指边界条件写错造成的循环次数差一的错误。最常见的原因是不小心把 < 和 <= 写错了

使用nullptr 而不是0或者NULL

  • 为什么不应该使用0或NULL来表示空指针

    • 0 : 字面量0可以是空指针(void*)0,也可以是数字0。这由上下文决定,因此,起初是空指针的东西,最后可能变成数字
    • NULL : NULL是一个宏,因此你不知道里面是什么
  • 空指针nullptr避免了数字0和宏NULL的歧义。nullptr的类型会一直是std::nullptr_t。

  • 你可以将nullptr赋值给任意一个指针,该指针就会变空,不指向任何数据。

  • nullptr可以显式的或基于上下文转换为bool类型。因此,你可以在逻辑表达式中使用nullptr。

用delete[]删除数组,用delete删除非数组对象

  • 手工内存管理,以及不使用STL的容器或智能指针(std:unique_ptr<X[]>)是非常容易出错的
  • 用非数组形式的delete来删除C数组是未定义行为

不要对无效指针解引用

  • 如果你对一个无效指针解引用,你的程序就会出现未定义行为。避免这种行为的唯一方法是在使用指针之前检查它
  • 如何解决此问题
    • 不要使用裸指针。
    • 如果需要像指针的语义,请使用智能指针

避免有未定义求值顺序的表达式

不要依赖函数参数的求值顺序

避免转型

  • 粗略来说,C风格转型会先从static_cast开始,接着是const_cast,最后执行reinterpret_cast

如果必须使用转型,请使用具名转型

  • 在C++11中,我们有以下六种转型
    • static_cast : 在类似的类型之间进行转换,例如指针类型或数字类型
    • const_cast : 添加或删除const或volatile
    • reinterpret_cast : 在指针之间或在整形和指针之间转换
    • dynamic_cast : 在同一个类层次中的多态指针或引用之间进行转换
    • std::move : 转换为右值引用
    • std::forward : 将左值转换为左值引用,或者将右值转换为右值引用

不要用转型去除const

  • 如果底层对象(例如 constInt)是const,而你试图修改底层对象,那么用转型去除const的行为是未定义行为

语句

  • 语句主要分为两类
    • 迭代语句和选择语句

迭代语句

  • C++实现了三种迭代语句: while, do while 和 for

  • 在C++11中,for循环中加入了语法糖: 基于范围的for循环

  • 基于范围的for循环更加容易阅读,而且在循环过程中不会出现索引错误,也不会对索引进行修改

  • 当你有一个明显的循环变量时,应该使用for循环而不是while语句。如果没有,那你应该使用while语句

  • 你应该在for循环中声明循环变量。要提醒你的是,从C++17开始,变量声明也可以在if或switch语句的初始化部分进行。

  • 避免使用do while语句和goto语句,并尽量不在迭代语句中使用break和continue,因为它们难以阅读。

    • 如果某样东西难以阅读,它就容易出错,并使你的代码重构变得困难。
    • break语句会结束迭代语句,而continue语句则结束当前迭代步骤。
  • 只要有合适的具名算法,就应该优先使用算法而不是原始循环。

  • STL的一百多个算法提供了对容器的隐式操作。这种操作常常可通过lambda表达式进行适配,还常常可以利用并行或者并行加向量化的版本来运行

选择语句

  • if 和 switch 是C++里从C语句继承下来的选择语句
  • 在可以选择时,你应该首选switch语句而不是if语句,因为switch语句通常更加可读,而且比if语句更好优化

不要依赖switch语句中的隐式直落行为

  • 从C++17开始,属性[[fallthrough]],可以明确地表达你的意图。[[fallthrough]]必须在它自己的语句行中,并紧接在一个case标签之前。
  • [[fallthrough]]向编译器表明,这里是估计要直落的。这样,编译器就不会发出诊断警告了。

仅使用default来处理一般情况

算术

  • 有符号和无符号证书的算术,以及典型的算术错误,例如上溢和下溢和除以零

不要混合有符号和无符号的算术运算

  • 如果你混合有符号和无符号的算术运算,你可能不会得到想要的结果

使用无符号类型进行位操作

  • 用位操作符(~, >>, >>=, <<, <<=, &, &=, ^, ^=, |, |=)对有符号操作数进行操作存在由实现定义的行为。
  • 实现定义的行为意味着该行为在不同的实现之间会不同,并且实现必须记录每种行为的效果。因此,不要在有符号类型上进行位操作,而应该使用无符号类型。

使用有符号类型进行算术运算

  • 首先,你不应该用无符号类型做算术,因为减法得到的负值在无符号类型中无法正确表达。
  • 其次,根据前面的规则,不应该混合有符号和无符号的算术

不要试图通过使用 unsigned 来避免负值

  • 有一个有趣的关系: 当你给unsigned int 变量赋值为-1时,你会得到最大的unsigned int

  • 算术表达式的行为在signed和unsigned类型之间有所不同

  • 检测溢出

    • 使用花括号表达式 x = {x + 1000}替换错误的赋值 x += 1000。
    • 它们的不同在于,编译器检查窄化转换,因此会检测到溢出

典型的算术错误

  • 如果违反以下三条规则,将会导致未定义行为
    • 避免上溢
    • 避免下溢
      • 上溢和下溢的效果是一样的: 内存损坏,因此是未定义行为
    • 不要除以零

本章精华

  • 如果你在手写循环,那说明你可能对标准模板库(STL)的算法还不够了解,STL包含一百多种算法
  • 好名字可能是号软件的最重要原则。好的名字意味着你的名字应该是自解释的,是尽可能局部的,不应该与现有的名字相似,不应该使用ALL_CAPS风格,也不应该在嵌套的作用域中重复使用
  • 始终初始化变量。优先使用{}初始化以防止窄化转换。对于const变量的复杂初始化,请使用就地调用lambda表达式
  • 不要把宏用于常量或函数。如果你必须时用它,或者维护现有的宏,请使用为一的ALL_CAPS名字
  • 可能的话,你应该优先选择基于范围的for循环而不是普通for循环。基于范围的for循环更加人容易阅读,而且不会引起下标错误
  • 应该优先选择switch语句,而不是if语句。switch语句更加容易阅读,而且有更多的优化潜力
  • 除非有特殊原因,否则应该使用有符号的整数,不要混合使用有符号和无符号算术
  • 请注意,上溢和下溢是未定义行为,通常在程序运行时以崩溃告终。

第九章 性能

错误的优化

  • 不要无故优化
  • 不要过早进行优化
  • 不要优化并非性能关键的东西
  • 有一句名言可以很好地总结前三条规则
    • 真正的问题是,程序员们花了太多的时间在错误的地方和错误的时间里担心效率问题:过早优化是编程中万恶之源(或至少是其中大部分罪恶之源)。–高德纳,《计算机程序设计艺术》
  • 简而言之,请务必记住 过早优化是编程中万恶之源 这句话。在进行任何新能假设之前,请使用最关键的规则来进行性能分析: 测量程序的性能

错误的假设

  • 不要假设复杂的代码一定比简单的快

  • 不要假设低级代码一定比高级代码块

  • 不要在没有测量的情况下对性能妄下断言

  • 在继续之前,必须先声明一下: 不建议使用臭名昭著的单例模式。单例模式有很多缺点。

设计应当允许优化

  • 这条规则尤其适用于移动语义,因为你写算法时应该使用移动语义,而不是拷贝语义。移动语义的使用会自动带来一些好处
    • 算法使用低开销的移动操作,而不是高开销的拷贝操作
    • 算法要稳定的多,因为它不需要内存分配,所以不会出现std::bad_alloc异常
    • 可将算法用于只能移动的类型,例如std::unique_ptr

依赖静态类型系统

  • 写本地代码

    • 一般来说,若使用就地调用的lambda表达式(而不是函数)来调整 std::sort的行为,代码会更快。编译器拥有所有可用的信息来生成最优化的代码。相反,函数可能定义在另一个翻译单元中,这对优化器来说是一个硬边界
  • 写简单代码

    • 优化器会搜寻可以被优化的已知模式。如果你的代码是手写的且极为复杂,这会使优化器寻找已知模式的工作变得更加困难。最终,你常常会得到没有充分优化的代码
  • 给予编译器额外的提示

    • 当函数不能抛出异常,或者你不关心异常时,将它声明为noexcept。如果一个虚函数不应该被覆盖,那么可将其声明为final,这对优化器来说也是很有意义的。

将计算从运行期移至编译期

  • constexpr 函数可以在运行期使用,也可以在编译期使用,如果要在编译期使用它,其参数必须是常量表达式。

以可预测的方式访问内存

本章精华

  • 在基于错误的假设进行任务所谓的优化之前,请测量程序的性能
  • 帮助编译器来优化程序。请使用移动语义来实现函数,如果可能的话,让他们成为constexpr
  • 现在计算机架构为连续读取内存而进行了优化。因此,应该将std::vector,std::array或std::string作为首选

第十章 并发

  • 并发 : 多个任务的执行可重叠。并发是并行的超集
  • 并行 : 多个任务再同一时间运行。并行是并发的子集

假设代码将作为多线程程序的一部分来运行

  • 对共享的非原子变量进行的异步读写是一种数据竞争。因此,程序具有未定义行为。

  • 有什么办法来摆脱数据竞争

    • 使用锁来保护整个临界区域
    • 用锁来保护函数cached_computation的调用
    • 将这两个静态变量都设置为thread_local。thread_local保证每个线程都能得到变量cached_x 和 cached_result。静态变量会被绑定到主线程的生存期。thread_local变量则绑定到线程的生存期
  • C++11标准保证C++运行期以线程安全的方式初始化静态变量;因此,不需要保护其初始化

避免数据竞争

  • 什么是数据竞争
    • 数据竞争是指至少有两个线程在异步的情况下访问一个非原子的共享变量,并且至少有一个线程试图修改该变量
  • 如果你的程序里有数据竞争,那么该程序有未定义行为。
  • 共享的,可变的状态是发生数据竞争的必要条件。

尽量减少对可写数据的显式共享

  • 根据前面与数据竞争相关的规则,共享数据应该是不变的
  • 现在,唯一需要解决的困难是如何以线程安全的方式初始化常量共享数据。C++11支持下面几种不同方法来实现这一点
    • 在启动线程之前初始化数据。这并不是C++11造成的,但却很容易应用
    • 使用常量表达式,因为它们在编译期被初始化
    • 将函数std::call_once与函数std::once_flag结合起来使用。可将重要的初始化内容放入函数onlyOnceFunc中。C++运行期保证这个函数只会成功运行一次;
    • 使用具有块作用域的静态变量,因为C++11的运行时保证它们会以线程安全的方式初始化

从任务(而不是线程)的角度进行思考

  • 什么是任务?

    • 任务是对执行单元的通用术语
  • 从C++11开始,我们把任务当作一个特别术语来用,它代表两个组成部分:诺值(promise)和期值(future)。

    • 诺值产生期值可以异步获取的值。
    • 诺值和期值可以在不同的线程中运行,然后通过安全的数据通道相连接
  • 诺值在C++中存在3中变体: std::async, std::packaged_task和std::promise

  • std::packaged_task和std::promise的共同点是它们都相当低级。

  • 线程和期值/诺值之间的根本区别是什么?

    • 线程是关于应该如何计算的;
    • 任务是关于应该计算什么的
  • 说的更加具体一点

    • 线程t使用共享变量res来提供结果。与此相反,std::async的诺值会建立一个安全的数据通道,并用它把结果传递给期值fut。这种数据共享意味着,对于线程t,必须保护res
  • 对于线程,你需要明确进行创建。对于诺值std::async,则不一定会自动创建线程。你指定应该计算什么,而不是应该如何计算。如果有必要,C++运行时会决定是否要创建线程

不要试图使用volatile进行同步

  • 在C++中,volatile并没有多线程语义。在C++11中,原子量被称为std::atomic

  • volatile用于特殊对象,不允许对其进行优化读或写操作。volatile通常用于嵌入式编程领域,表示可以独立于常规程序流程而改变的对象。比如,这些对象代表外部设备(内存映射I/O)。因为这些对象可以独立于常规程序流程进行更改,它们的值会被直接写入主存。因此,编译期不能假设它可以根据程序流来判断是否可对读写进行优化

只要可行,就使用工具来对并发代码进行验证

  • 动态代码分析工具ThreadSanitizer和静态代码分析工具CppMem

  • ThreadSnaitizer提供了全局信息,检测程序的执行是否存在数据竞争

  • Cppem能让你深入理解代码里的小片段,大多数是否包括原子量。你会得到问题的答案:根据内存模型,那些情况下交错是可行的

  • ThreadSanitizer(又名TSan)是一个C/C++的数据竞争检测器。数据竞争是并发系统中最常见和最难处理的错误之一。当两个线程同时访问同一个非原子变量,并且其中至少有一个线程的访问是写操作时,数据竞争就会发生。C++11标准正式禁止数据竞争,并将其归为未定义行为

  • ThreadSanitizer是Clang3.2和GCC4.8的一部分。要使用它,必须使用-fsanitize=thread进行编译和链接,并至少使用优化级别-O2和产生调试信息的标志-g。

    • -fsanitize=thread -O2 -g
  • 它有很大的运行期开销

    • 内存使用量可能会增加5到10倍,执行时间也可能增加2到20倍
  • 软件开发的突出法则

    • 首先,让程序正确
    • 然后,让它快起来

  • NNN是 No Naked New(不要裸的New)的缩写,意味着内存分配不应该是一个独立的操作,而应该放在一个管理者对象中进行
  • 互斥量也是如此。互斥量应立即交付给一个管理者对象,在这种情况下,它就是锁对象。
  • 在现代C++中,我们有std::lock_guard, std::unique_lock, std::shared_lock(C++14), std::scoped_lock(C++17)
  • 请记住缩写NNM,它代表 No Naked Mutex,锁实现了RAII惯用法。RAII惯用法背后的关键思想是将资源的生存期和局部变量的生存期绑定。C++会自动管理局部变量的生存期

使用RAII,永远不要直接使用lock()/unlock()

  • 将互斥量放入锁对象中,互斥量在std::lock_guard的构造函数中被自动锁定,并在lck超出作用域时解锁

使用std::lock()或std::scoped_lock来获取多个互斥量

  • 如果一个线程同时需要多个互斥量,那么必须非常小心,始终以相同的顺序锁定互斥量。否则,糟糕的线程的交错可能会导致死锁
  • 解决死锁的最简单方法是以原子方式锁定两个互斥量
  • 在C++11中,可将std::unique_lock和std::lock一起使用
  • 在C++17中,std::scoped_lock可以原子性的锁定任意数量的互斥量。

决不在持有锁的时候调用未知代码(例如回调函数)

线程

  • 线程是并发和并行编程的基本构建块。

将汇合thread看作一个有作用域的容器

将thread看作一个全局容器

  • 首先,必须汇合或分离子线程。如果不这样做,会在子线程的析构函数中得到std::terminate

  • 一个线程可以被看作一个使用外部变量的全局容器。此外,对于汇合线程,容器的生存期是有作用域的。

优先选择std::jthread,而不是std::thread

  • 这条规则的原标题是: 优先选择gsl::join_thread,而不是std::thread

  • 这里用C++20中的std::jthread替换了C++ Core Guidelines支持库中的gsl::joint_thread

  • 除了有std::thread的功能,std::jthread会在析构时自动汇合。

不要对线程调用detach()

  • 分离线程的难度很大

不要在没有条件时wait

  • 条件变量(condition variable)支持一个非常简单的概念:一个线程准备好了一些东西,并向等待它的另一个线程发送通知。

  • 条件变量受到两个很严重的问题的影响

    • 丢失唤醒(loss wakeup): 丢失唤醒的现象是,发送方在接收方开始等待之前发送了通知。结果通知丢失
    • 虚假唤醒(spurious wakeup): 即使没有发送通知,接收方也可能会被唤醒。至少POSIX线程和WindowsAPI会是这些现象的受害者

数据共享

  • 共享数据使用的越少,并且局部变量使用的越多,结果就越好。不过,有时除了共享数据,你别无选择,例如,当子线程想把它的工作传达给父线程时

在线程之间传递少量的数据使用值传递,而不是引用或指针

  • 将数据按值传递给线程有两个好处
    • 没有共享,因此,不可能有数据竞争。数据竞争的前提是有可变并共享的状态
    • 你不需要关心数据的生存期。数据的生存期和所创建的线程的生存期保持一致

要在不相关的thread之间分享所有权,请使用shared_ptr

资源

  • 使用并发的主要原因之一是性能。
  • 必须牢记,使用线程时需要耗用资源: 时间和内存。
  • 资源的使用从创建开始,然后随着上下文切换从用户空间到内核空间,并以线程销毁而结束。
  • 此外,线程由有自己的状态,需要分配和维护

尽量减少上下文切换

尽量减少线程的创建和销毁

尽量减少临界区内的时间占用

  • 锁定互斥量的时间越短,其他线程可运行的时间就越长

记得给你的lock_guard和unique_lock起名字

  • 如果你没有给std::lock_guard或std::unique_lock起名字,那你只是创建了一个临时变量,它在创建后会立即销毁。
  • std::lock_guard或std::unique_lock会自动锁定其互斥量和构造函数,并在其析构函数中解锁。这种模式被称为RAII

关于并行

  • 优先使用STL的并行算法,而不是用线程手写解决方案

  • std::execution::seq : 顺序执行算法

  • std::execution::par : 在多个线程上并行的运行该算法

  • std::execution::par_unseq : 在多个线程上并行运算算法,并允许单个循环的交错

  • 向量化std::execution::par_unseq代表现代处理器指令集的SIMD(单指令多数据)扩展。SIMD使处理器能够在多个数据上并行的执行同一个操作。

消息传递

  • 关于消息传递,有两条规则

    • 使用future从并发任务返回
    • 使用async()生成并发任务
  • 任务是一种C++式的在线程之间传递消息的方式。

  • 消息可以是值,异常或者通知。

  • 任务由诺值和期值两个部分组成。诺值创建消息,期值异步的进行接收

  • 发送值或异常

    • 与线程非常不同,诺值和相关联的期值会共享一个安全通道
  • 发送通知

    • 如果你使用诺值和期值(在短任务中)来同步线程,它们与条件变量有很多共同之处。
    • 大多数情况下,诺值和期值是比条件变量更加安全的选择
  • 跟诺值和期值相比,条件变量的优势在于,可以使用条件变量来多次同步线程。

  • 与此相反,一个诺值只能发送一次通知。而如果你只为单次同步使用条件变量,要么把条件变量用对会比用诺值和期值难得多。一对诺值和期值不需要锁,不会出现虚假唤醒和丢失唤醒,也不需要临界区或额外的条件判断

无锁编程

  • 并发和并行的规则是针对非专家的。无锁编程(lock-free programming)是一个仅面向专家的主题。

除非绝对需要,否则不要使用无锁编程

不要轻信硬件/编译器组合

本章精华

  • 区分并发和并行。并发是指多个任务的重叠,而并行是指多个任务同时运行
  • 通过尽量减少数据的共享来避免数据竞争,并使共享的数据不可变
  • 使用ThreadSanitizer或Cppem等工具来验证并发代码
  • 不要直接对互斥量加/解锁。将互斥量放入锁对象中,比如std::lock_guard或者std::unique_lock
  • 不要在持有锁的时候调用未知代码。尽量不要在任一时间点获取超过一把锁
  • 当你在某个时候需要超过一把锁时,使用std::lock或者std::scoped_lock来原子性的获取
  • 使用std::jthread而不是std::thread,以便在析构时自动重新汇合
  • 不要使用没有附加谓词的条件变量,以避免虚假唤醒和丢失唤醒
  • 如果逆向并行的执行一项工作,最好使用STL的并行算法,而不是用线程来手工实现解决方案
  • 使用任务在线程之间传递消息或异常。使用任务(而不是条件变量)来同步线程
  • 除非绝对需要,否则不要使用无锁编程。事先请仔细研读文献

第十一章 错误处理

  • 根据C++ Core Guidelines,错误处理涉及一下操作

    • 检查错误
    • 将有关错误的信息传递给某些处理程序代码
    • 保存程序的有效状态
    • 避免资源泄漏
  • 应该使用异常来处理错误

  • C++ Core Guidelines中的规则应该可以帮助避免以下几种类型的错误,这里在括号中添加了典型的例子

    • 类型违规(转型)
    • 资源泄漏(内存泄漏)
    • 边界错误(访问到了容器的边界外)
    • 生存期错误(删除后访问对象)
    • 逻辑错误(逻辑表达式)
    • 接口错误(在借口中传递错误的值)
  • 总共二十多条规则,可以分为三类

    • 前两类说的是错误处理策略的设计及其具体实现
    • 第三类讨论不能抛出异常的情况

设计

  • 每个软件单元都有两条与客户的通信途径

    • 一条用于常规情况
    • 另一条用于非常规情况
  • 软件单元应该围绕不变式进行设计

  • 通信

    • 在设计初期制订错误处理策略
    • 不要试图在每个函数中捕获每个异常
    • 尽量减少显式的try/catch使用
  • 什么是软件单元?

    • 软件单元可以是函数,对象,子系统或者整个系统。
  • 软件单元与客户进行通信。因此,通信设计应该在系统设计初期进行。

  • 在边界层面,有两种通信方式:常规和非常规。

    • 常规通信是接口的功能方面,换句话说,它规定软件单元应该做什么
    • 非常规通信代表非功能方面。非功能方面规定了系统的运行方式。非功能方面的很大一部分是错误处理,即什么会出错。通常,非功能方面被称为 质量属性
  • 从控制流的角度来看,显式的try/catch与goto语句有很多共同之处。这意味着如果异常被抛出,控制流会直接跳转到异常处理器,而这段代码可能在不同的软件单元中。

  • 现在的问题是,应该如何组织异常处理?我认为你应该先问自己一个问题:是否可能在本地处理异常?

    • 如果是,那就去做;如果否,那就让异常传播,直到有足够的上下文来处理它。处理异常也可能意味着捕获它,然后重新抛出一个不同的,对客户更方便的异常。这种对异常的转换可以达到这样的目的:软件单元的客户只需要处理数量有限的不同异常
  • 通常情况下,边界是处理异常的合适位置,因为你会希望保护客户端,使其不会随便受异常的影响。

  • 不变式

    • 通过抛出异常来表明函数无法执行其分配的任务
    • 围绕不变式来设计错误处理策略
    • 让构造函数建立不变式,做不到就抛出异常
  • 根据C++ Core Guidelines,不变式是 对象成员的一种逻辑条件,构造函数必须建立这个条件,这样公开成员函数就可以假设它一定成立。在不变式建立后(通常通过构造函数),对象上的每个成员函数就可以调用了

  • 更多关于不变式和如何建立不变式的规则,这些规则补充了本章开头的讨论

    • 当类具有不变式时使用class;如果数据成员可以独立变化,则使用struct
    • 构造函数应该创建完全初始化的对象
    • 不要定义仅初始化数据成员的默认构造函数,而应该使用成员初始化器

只对错误处理使用异常

  • 异常是一种goto语句

应使用专门设计的用户定义类型(而非内置类型)作为异常

  • 你不应该使用内置类型,甚至不应该直接使用标准的异常类型
  • 建议使用标准异常而不是内置类型,因为前者可以给异常附加额外的信息,或者构建异常层次结构。
  • 标准异常也只是相对稍好,但还是算不上号,因为这个异常太通用了。
  • 要解决这些问题,请从std::runtime_error派生出你的具体异常,下面是一个简短的例子
    1
    2
    3
    4
    5
    6
    7
    class InputSubsystemException : public std::runtime_error
    {
    const char* what() const noexcept override
    {
    return "在这里提供异常的更多细节";
    }
    };

通过引用从层次结构中捕获异常

  • 如果按值捕获层次结构中的异常,那么你可能会成为切片的受害者

  • 多态类应当抑制公开的拷贝/移动操作,说的明确一点

    • 应该通过const引用来捕获异常;仅在想要修改异常时通过(非const)引用来捕获
    • 如果你要在异常处理器中重新抛出异常e,只需要使用throw而不是throw e;在第二种情况下,e会被复制
  • 要解决按值捕获异常,有个简单的办法:

    • 应用规则:如果基类被当作接口使用,那就把他变成抽象类

在直接拥有对象时决不抛异常

  • 如果throw被激发,那内存会丢失,你就有了内存泄漏。
  • 简单的解决方案是摆脱所有权,让C++运行时成为对象的直接所有者。这意味着只需要应用RAII

不要使用异常规格

正确排列catch子句的顺序

  • 异常的捕获是根据第一次匹配策略进行的。这意味着第一个匹配成功的异常处理器将被使用。这就是应该从具体到一般来组织异常处理器的原因。否则,你的具体异常处理器可能永远不会被调用。

  • 异常处理器有省略号(…),用来捕获所有其他的异常

如果不能抛出异常

  • 如果不能抛出异常,请模仿RAII进行资源管理

  • 如果不能抛出异常,考虑快速失败

    • 如果没有办法从内存耗尽等错误中恢复过来,那就快速失败。如果你不能抛异常,就调用std::abort,促使程序异常终止
  • 如果不能抛出异常,请系统的使用错误代码

  • 根据C++ Core Guidelines,如果出现错误,你有几个问题需要解决

    • 如何将错误指示从函数中传出
    • 在进行错误推出之前,如何从函数中释放所有资源
    • 使用什么作为错误指示
  • 一般来说,函数应该有两个返回值: 值和错误提示。 因此,std::pair就很合适。

  • 不过,即使清理代码被封装在函数中,释放资源也很容易成为维护的噩梦

  • 把清理代码放在函数的末尾,并跳转到该部分即可。

本章精华

  • 软件单元通过常规和非常规的通信途径将结果传达给客户。错误处理是非常规途径的一个主要部分,应该在设计初期进行设定
  • 围绕不变式设计错误处理。构造函数的工作是建立不变式。如果不变式不能被建立,则抛出异常
  • 使用用户定义类型来表示异常。通过引用来捕获异常,顺序应该从具体到一般
  • 仅将异常用于错误处理
  • 切勿直接拥有对象。始终使用RAII类型来管理任何需要释放的资源。RAII有助于资源管理,即使不使用异常,也是如此。

第十二章 常量和不可变性

  • 无论如何,const,constexpr和不可变性的思想太重要了。

使用const

  • const正确性,是指 意味着使用关键字const来防止const对象被改变

默认情况下,使对象不可变

  • 可以将内置数据类型的值或用户定义的数据类型的实例设为const,其效果是一样的。如果想改变这个对象,就会得到编译错误
  • 如果底层对象是const,则转型去除const的操作可能会导致未定义行为,参见 不要转型去除const

默认情况下,成员函数应该声明为const

  • 声明成员函数为const的做法有两个明显的好处。

    • 不可变的对象只能调用const成员函数
    • 而const成员函数不能修改底层对象
  • 物理常量性(physical constness)

    • 对象被声明为const,因此不能被改变。它在内存中的表示方法是固定的
  • 逻辑常量性(logical constness)

    • 对象被声明为const,但可以被改变。它的逻辑值是固定的,但它在内存中的表示方式可能在运行期发生变化

默认情况下,传递指向const对象的指针和引用

使用const定义构造后值不可变的对象

  • 在并发环境中使用不可变数据和共享数据时,还有一个问题需要解决:必须以线程安全的方式初始化共享变量。对此,我能够想到至少四种做法
    • 在启动线程前初始化共享变量
    • 使用函数std::call_once与标志std::once_flag
    • 使用具有块作用域的static变量
    • 使用constexpr变量

对可以在编译期计算的值使用constexpr

  • constexpr值可以提供更好的性能。会在编译期进行计算,并且永远不会受到数据竞争的影响。必须在编译期初始化constexpr值constexprValue

本章精华

  • 默认情况下,使对象不可变。不可变对象不会受到数据竞争的影响。确保以线程安全的方式初始化这些对象
  • 默认情况下,成员函数应声明为const。辨别你的对象是物理const还是逻辑const
  • 不要用转型从原始const对象中去除const。如果你试图修改该对象,那这种转换是未定义行为
  • 如果可能,把函数声明为constexpr。constexpr函数可以在编译期运行。它在编译期运行时是纯函数,并提供额外的优化机会

第十三章 模板和泛型编程

  • 暂不接触,不做记录

第十四章 C风格变成

优先使用C++而不是C

  • 原因:C++提供更好的类型检查和更多的写法支持。它为高级编程提供了更好的支持,并往往生成更快的代码

完整的源代码可用

没有完成的源代码

  • 使用C++编译期编译main函数
    • 与C编译期不同,C++编译期会生成在main函数之前执行的额外启动代码
  • 使用C++编译期链接程序
  • 使用来自同一供应商的C和C++编译期

如果必须使用C作为接口,则调用此类接口的代码里使用C++

  • 通过使用extern “C”链接说明符,可以防止C++编译器重编这些名字。这样,你既可以从C++调用C函数,也可以从C调用C++函数
  • 可以把extern “C”用在
    • 每个函数前
    • 一个作用于内的每个函数
    • 整个头文件(通过使用包含保护宏)。当使用C++编译器时,宏__cplusplus会被定义

如果必须使用C,请使用C和C++的公共子集,并以C++的方式编译C代码

本章精华

  • 如果必须支持C代码,请使用C++编译器编译C代码。如果不可能那样做,请使用C++编译期编译main函数,并使用C++链接器链接程序。使用同一供应商的C和C++编译期
  • 通过使用extern “C”链接说明符,可以防止C++编译器重编名字。这样,你既可以从C++调用C函数,也可以从C调用C++函数。

第十五章 源文件

  • 我们应该区分代码的实现和接口
  • 区分声明(用作接口)和定义(用作实现)。使用头文件表示接口并强调逻辑结构。

接口和实现文件

  • 声明或接口通常实现在*.h文件中,定义或实现则在*.cpp文件中

如果你的项目还没有采用其他约定,那代码文件使用.cpp后缀,接口文件使用.h后缀

  • 头文件

    • *.h
    • *.hpp
    • *.hxx
    • *.inl
  • 实现文件

    • *.cpp
    • *.c
    • *.cc
    • *.cxx

.h文件不可含有对象定义或非内联函数定义

  • 单一定义定义规则,ODR是 One Definition Rule。下面是它在函数方面的规定
    • 一个函数在任何翻译单元中不能有一个以上的定义
    • 一个函数在程序中不能有一个以上的定义
    • 有外部链接的內联函数可以在一个以上的翻译单元中被定义。这些定义必须满足一个要求,它们全部相同

.cpp文件必须包含定义其接口的.h文件

为所有的.h文件使用#include防护宏

  • 通过在头文件首尾放置包含防护宏,头文件就只会被包含一次
  • 有两点需要注意
    • 给你的防护宏一个唯一的名字。如果你多次使用同一个防护宏名字,可能导致本应该包含的头文件被排除在外
    • #pragma预处理指令不标准,但广泛受到支持。这个pragma指令意味着下面这种头文件的写法是不可被移植的

避免源文件间的循环依赖

  • 直接的解决方法是,在b.h中前置声明A,或者在a.h中前置声明B
  • 标准库头文件 中有标准输入/输出的前置声明

避免对隐含 #include 进来的名字的依赖

头文件应当是自包含的

  • 一个自包含的头文件可被包含在翻译单元的最上面。
  • 自包含的意思是,头文件不依赖于之前包含的其他文件。

仅对代码迁移,基础程序库(例如std)或者在局部作用域中使用using namespace指令

  • using指令隐藏了名称的来源,且破坏了代码的可读性

不要在头文件的全局作用域中使用using namespace

使用namespace表示逻辑结构

不要在头文件中使用无名(匿名)命名空间

为所有的内部/不导出的实体使用无名(匿名)命名空间

  • 无名namespace使用内部链接。内部链接意味着无名namespace内的名称只能在当前的翻译单元内引用,而不能导出。这同样适用于在无名namespace中声明的名称

  • 当你在头文件中使用无名namespace时,每个翻译单元都定义了这个无名namespace的唯一实例。头文件中的无名namespace会导致

    • 所产生的可执行文件大小膨胀
    • 无名namespace中的任何声明都是指每个翻译单元中的不同实体。这可能不是无名所期望的行为
  • 无名namespace的用法类似于C语言里使用的static关键字

本章精华

  • 头文件不应包含对象定义或非內联函数。它们应该是自包含的并且有#include防护宏。不要在头文件中使用using namespace
  • 源文件应该包含必要的头文件,并且避免循环依赖
  • 命名空间应该表达软件的逻辑结构。如果可能的话,应当避免使用using namespace指令以提升可读性

第十六章 标准库

优先采用STL的array或vector而不是C数组

  • 与C数组相比,std::vector的一大优势是可以自动管理内存

  • vector的大小是指它的元素数

  • 容器的容量是指一个vector在没有额外的内存分配的情况下可以容纳的元素数

  • 因此,一个vector的容量至少要和它的大小一样大。我们可以用方法resize来调整一个vector的大小,也可以用成员函数reserve来调整一个容器的容量

  • 有几点需要说明

    • 容器大小和容量的调整是自动完成的,不需要使用任何像new和delete那样的内存操作
    • 通过调用成员函数vec.resize(n),如果 n > vec.size(),那么vec这个vector里面的元素会默认初始化
    • 通过使用成员函数vec.reserve(n),如果n > vec.capacity(),那么容器vec会获得至少能够容纳n个元素的新内存
    • shrink_to_fit的调用是没有约束力的。这意味着C++运行时并非必须按照容器的大小来调整其容量。但是到目前为止,在GCC, Clang或cl.exe汇总用到成员函数shrink_to_fit总是会释放掉不必要的内存

默认应优先采用STL的vector,除非有理由使用别的容器

  • 如果你想在运行期向你的容器中添加元素或者从你的容器中删除元素,请使用std::vector,否则,请使用std::array.

  • 此外,std::vector可以比std::array大的多,因为它的元素会放在堆里。std::array使用的缓冲区则和使用它的上下文在一起

  • std::array和std::vector具有以下优点

    • 最快的通用访问(随机访问,包括对CPU向量化友好)
    • 最快的默认访问模式(从头到尾或者从尾到头的访问对CPU缓存预取友好)
    • 最低的空间开销(连续布局的每个元素额外开销为零,对CPU缓存友好)

避免边界错误

  • C数组本身不支持检测边界错误。而STL的许多容器都支持一个容器检查边界的at成员函数。在访问一个不存在的元素的情况下,会抛出一个std::out_of_range异常
  • 下面的容器都有一个带边界检查的at成员函数
    • 序列容器: std::array, std::vector和std::deque
    • 关联容器: std::map 和 std::unordered_map
    • std::string

文本

  • std::string : 拥有一个字符序列
  • std::string_view : 指向一个字符序列
  • char* : 指向单个字符
  • std::byte : 描述字节值(不一定是字符)
  • 总结一下:
    • 只有std::string是一个所有者,其他的都是指向已有的文本

使用std::string 来拥有字符序列

使用std::string_view来指向字符序列

  • std::string_view指向一个字符序列。说的明确一些,std::string_view并不拥有该字符序列。它代表的是一个子符序列的视图。这个字符序列可以是一个C++字符串或C字符串
  • std::string_view需要两样信息:指向字符序列的指针和长度。

用char*来指向单个字符

使用std::byte来指向未必表示字符的字节值

  • std::byte(C++17)是实现C++语言定义中规定的字节概念的一个独立类型。这意味着字节即不是整数,也不是字符。
  • 它的作用是访问对象存储。std::byte的接口包含了比特位逻辑操作的方法

对当作标准库string使用的字符串字面量使用后缀s

  • 在C++14之前,没办法不用C字符串来创建C++字符串
  • 到了C++14,我们有了C++字符串字面量,它们是C字符串字面量加上后缀s: “CstringLiteral”s

输入和输出

  • 当你和外部世界交互时,有两个输入/输出库发挥作用:

    • 基于流的I/O库(简称为iostream库)
    • C风格I/O函数
  • 当然你应该优先选择iostream库。

    • iostream是一种用于流式I/O的类型安全,可扩展,支持有格式和无格式输出的库。他支持多种(用户可扩展)缓冲策略和多种地域设置。它可被用于传统的I/O,对内存的读写(字符串流),以及用户定义的扩展,例如跨网络的流(asio)

仅在必要时使用字符级输入

当进行读取时,始终要考虑非法输入的情况

  • 只有当流处于std::ios::goodbit状态时,对流的操作才会产生影响。当流处于std::ios::badbit状态时,它不能被重置为std::ios::goodbit状态

优先使用iostream进行I/O操作

  • printf和iostream库之间有一些微妙但是关键的区别
    • printf的格式字符串指定格式和显式值的类型,而iostream库的格式操纵器只指定格式。
    • 反过来说,在使用iostream库时,编译器会自动推断出正确的类型

除非你要使用printf系列函数,否则应该调用ios_base::sync_with_stdio(false)

  • 默认情况下,对C++流的操作与C流的操作是同步的。这种同步发生在每个输入或输出操作之后

    • C++流: std::cin, std::cout, std::cerr, std::clog, std::wcin, std::wcout, std::wcerr 和 std::wclog
    • C流: stdin, stdout 和 stderr
  • 这种同步允许混合C++和C的输入或输出操作,因为对C++流的操作会不加缓冲的进入C流中。

  • 从并发的角度来看,还需要注意的是,同步的C++流是线程安全的。所有的线程都可以写到C++流,而不需要同步机制,有可能会出现字符交错的效果,但不会有数据竞争

  • 当你设置std::ios_base::sync_with_stdio(false)时,C++流和C流之间的同步不会发生,因为C++流可能会把它们的输出放到一个缓冲区里。有了缓冲区之后,输入和输出的操作可能会变得更快。

  • 你应该在任何输入或输出操作之前调用std::ios_base::sync_with_stdio(false)。如果不这样做,行为将由实现决定

避免使用endl

  • 操纵器std::endl和’\n’之间有什么区别
    • std::endl : 写一个换行符并刷新输出缓冲区
    • ‘\n’ : 写一个换行符
  • 刷新缓冲区操作代价较高,因此应该避免。如果有必要,缓冲区会被自动刷新

相关规则

  • 理想情况下,程序应该是静态类型安全的
  • 不要用单个指针来传递数组
  • 保持指针的使用简单明了
  • 避免对范围检查的需要

本章精华

  • 使用std::array或std::vector而不是C数组。如果容器必须在运行其曾长,或者元素的数量对std::array来说太大,那么首选std::vector而不是std::array.std::vector和std::array支持使用at成员函数对元素进行安全访问
  • C++有多种文本支持方式。std::string文本的所有者,而std::string_view, const char *只是指向文本。另外,std::byte包含字节值(不一定是字符)
  • 在输入/输出功能方面,首选iostream库而不是C风格的函数。读取文本时,始终要考虑非法输入

第十七章 架构观念

从不稳定的代码中分离稳定的代码

  • 格里不稳定的代码有助于单元测试,接口改进,重构和最终的废弃

  • 在稳定的和不太稳定的代码之间放置一个接口是一种分离的方式。由于接口的存在,不稳定的代码变成了一种子系统,你可以单独测试或重构它。

  • 这样你不仅可以测试子系统,还可以测试子系统和系统的集成。

    • 第一种测试通常被称为子系统测试
    • 第二种测试通常被称为系统集成测试
  • 子系统有两个进入系统的通道: 功能通道和非功能通道。两者都必须被测试

    • 功能通道提供了子系统的功能
    • 非功能通道则传播了可能发生的异常,系统可以对其作出反应。
  • 由于接口的存在,具体的子系统是接口的实现,因此可以很快被另一个可能更稳定的实现所代替

将潜在可复用的部分表达为程序库

  • 基于 你不会需要它(you aren’t gonna need it, YANGNI)原则,不要在代码中预先投入过多的精力以使其成为可重用的库,而是要先写出代码,使其有可能被复用。

  • 不要重复自己(DRY)原则,当你不止一次需要相同或者类似的功能时,它就会发生作用。这时,你应该考虑以下如何抽象。当我有两个类似的函数时,我会写第三个函数来提供实现,而那两个类似的函数则成为使用这个函数的封装

  • 以库的形式编写可复用的软件,比做一次性的实现要花三到四倍的精力。

  • 我的经验法则是,当知道你会重复使用某个功能时,应该考虑库的问题。而只有当你会重用某功能至少两次时,才应该把它写成一个库。

程序库之间不应该有循环依赖

  • 库C1和C2之间的循环依赖使你的软件系统更加复杂
    • 重新包装C1和C2,使它们不再相互依赖
    • 在物理上将C1和C2合并成一个单独组件C12
    • 把C1和C2放在一起考虑,把两者当作一个单独组件C12

第十八章 伪规则和误解

不要坚持认为声明都应当放在函数的最上面

  • 这条规则是C89标准的遗留问题。C89不允许在语句之后声明一个变量。这导致变量声明和使用之间有很大的距离。
    • 将变量的声明放在其第一次使用之前
    • 始终初始化一个变量,例如int i{}或者最好使用auto

不要坚持在一个函数中只保留一条return语句

不要避免使用异常

  • 反对异常四个主要原因

    • 异常是低效的
    • 异常会导致泄漏和错误
    • 异常的性能不可预测
    • 异常处理的运行需要太多空间
  • 通过使用异常,可以

    • 明确区分错误的返回和普通的返回
    • 不会被遗忘或忽视
    • 可以系统的使用

不要坚持把每个类声明放在独立的源文件中

  • 组织代码的合适方式并不是用文件,正确的方式是使用命名空间。
  • 如果为每个类的声明使用一个独立的文件,将产生过多的文件,进而使你的程序更难管理,编译更慢

不要采用两阶段初始化

  • 显然,构造函数的工作很简单: 在执行构造函数之后,应该有一个完整初始化的对象

  • 你也许经常使用构造函数来设置一个对象的默认行为。不要这样做,而应该直接在类的主题中设置对象的默认行为。

  • 构造函数只是用来改变这些默认行为。参见 不要定义一个只初始化数据成员的默认构造函数,而应该使用成员初始化器

不要把所有清理操作放在函数末尾并使用goto exit

不要使所有数据成员protected

  • 受保护的数据使程序变得复杂且容易出错。如果把受保护的数据放到基类中,你就不能孤立的仅根据派生类来推理,因此,你破坏了封装。
  • 受保护的数据意味着你至少要回答以下三个问题
    • 我是否必须在派生类中实现一个构造函数来初始化受保护的数据
    • 如果我使用受保护的数据,它的实际价值是什么
    • 如果我修改受保护的数据,谁会受到影响
  • 类的层次越深,这些问题的答案就会变得越复杂
  • 受保护的数据是类层次结构范围内的一种全局数据。而你知道,可变的,共享的状态非常糟糕。比如,它会使测试和并发的处理变得相当棘手

简介

1.1

  • 要编写通过计算机网络通信的程序,首先要确定这些程序相互通信所用的协议(protocol)

  • 在深入设计一个协议的细节之前,应该从高层次决断通信由哪个程序发起以及响应在何时产生

  • 举例来说,一般认为Web服务器程序是一个长时间运行的程序(即所谓的守护程序,daemon)

  • 同一网络应用的客户和服务器无需处于同一个局域网(local area network, LAN)

  • 两个局域网是使用路由器(router)连接到广域网(wide area network, WAN)

  • 路由器是广域网的架构设备。当今最大的广域网是因特网(Internet)

  • 许多公司也构建自己的广域网,而这些私有的广域网既可以连接到因特网,也可以不连接到因特网

1.2

  • socket函数创建一个网际(AF_INET)字节流(SOCK_STREAM)套接字,它是TCP套接字的花哨名字。

  • 该函数返回一个小整数描述符,以后的所有函数调用(例如connect和read)就用该描述符来标识这个套接字

  • 后面,将遇到术语套接字(socket)的许多不同用法。

  • 首先,正在使用的API称为套接字API(sockets API),socket函数就是套接字API的一部分

  • TCP套接字,它是TCP端点(TCP endpoint)的同义词

  • 如果socket函数调用失败,我们就调用自己的err_sys函数,放弃程序的运行

  • err_sys函数输出我们作为参数提供的出错消息以及所发生的系统错误的描述

1.3

  • connect函数应用于一个TCP套接字时,将与由它的第二个参数只想的套接字地址结构指定的服务器建立一个TCP连接。
  • 该套接字地址结构的长度也必须作为该函数的第三个参数指定,对于网际套接字地址结构,我们总是使用C语言的sizeof操作符由编译器来计算这个长度

1.4

  • 使用read函数读取服务器的应用,并用标准的I/O函数fputs输出结果。

  • 使用TCP时必须小心,因为TCP是一个没有记录边界的字节流协议。

  • 计算机网络各层对等实体间交换的单位信息,称为协议数据单元(protocol data unit, PDU)

  • 分节(segment)就是对应于TCP传输层的PDU

  • 按照协议与服务之间的关系,除了最底层(物理层)外,每层的PDU通过由紧邻下层提供给本层的服务接口,作为下层的服务数据单元(service data unit, SDU)传递给下层,并由下层间接完成本层的PDU交换

  • 应用层实体(例如客户和服务器进程)间交换的PDU称为应用数据(application data),

  • 其中在TCP应用进程之间交换的是没有长度限制的单个双向字节流

  • 在UDP应用进程之间交换的是其长度不超过UDP发送缓冲区大小的单个记录(record)

  • 在SCTP应用进程之间交换的是没有总长度限制的单个或多个记录流

  • 传输层实体(例如对应某个端口的传输层协议代码的一次运行)间交换的PDU称为消息(message),其中TCP的PDU特称为分节(segment)。

  • 消息或分节的长度是有限的。

  • 在TCP传输层中,发送端TCP把来自应用进程的字节流数据(即由应用进程通过一次次输出操作写出到发送端TCP套接字中的数据)按顺序分割后封装在各个分节中传送给接收端TCP。

  • 其中,每个分节所封装的数据既可能是发送端应用进程单次输出操作的结果,也可能是连续数次输出操作的结果,而且每个分节所封装的单次输出操作的结果或者首尾两次操作的结果既可能是完整的,也可能是不完整的,具体取决于可在连接建立阶段由对端通告的最大分节大小(maximum segment size, MMS)以及外出接口的最大传输单元(maximum transmission unit, MTU)或外出路径的路径MTU(如果网络层具有路径MTU发现功能,例如IPv6)

  • 通常服务器返回包含所有26个字节的单个分节,但是如果数据量很大,我们就不能确保一次read调用能返回服务器的整个应答。

  • 因此从TCP套接字读取数据时,我们总是需要把read编写在某个循环中,当read返回0(表明对端关闭连接)或负值(表明发生错误)时终止循环

1.5

  • 网络层实体间交换的PDU,称为IP数据报(IP datagram),其长度有限:IPv4数据报最大65535字节,IPv6数据包最大65575字节。

  • 发送端IP把来自传输层的消息(或TCP分节)整个封装在IP数据报中传送

  • 链路层实体间交换的PDU,称为帧(frame),其长度取决于具体的接口。

  • IP数据报由IP首部和所承载的传输层数据(即网络层的SDU)构成

  • 过长的IP数据报无法封装在单个帧中,需要先对其SDU进行分片(fragmentation),再把分成各个片段(fragment)冠以新的IP首部封装到多个帧中

  • 在一个IP数据报从源端到目的端的传送过程中,分片操作既可能发生在源端,也可能发生在途中,而其逆操作,即重组(reassembly)一般只发生在目的端

  • SCTP为了传送过长的记录采取了类似的分片和重组措施

  • TCP/IP协议族为了提高效率会尽可能避免IP的分片/重组操作;TCP根据MSS和MTU限定每个分节的大小以及SCTP根据MTU分片/重组过长的记录都是这个目的

  • 不论是否分片,都由IP作为链路层的SDU传入链路层,并由链路层封装在帧中的数据称为,分组(packet, 俗称包)

  • 可见,一个分组既可能是一个完整的IP数据报,也可能是某个IP数据报的SDU的一个片段被冠以新的IP首部后的结果

  • 另外,

    • 本书讨论的MSS是应用层(TCP)与传输层之间的接口属性,
    • MTU则是网络层和链路层之间的接口属性

1.6

  • 任何现实世界的程序,都必须检查每个函数调用是否返回错误。

  • 既然发生错误时终止程序的运行是普遍的情况,我们可以通过定义包裹函数(wrapper function)来缩短程序。

  • 每个包括函数完成实际的函数调用,检查返回值,并在发生错误时终止进程

  • 我们约定的包裹函数是实际函数名的首字母大写形式,例如

    • sockfd = Socket(AF_INET, SOCK_STREAM, 0);
  • 其中,函数Socket是函数socket的包裹函数

    1
    2
    3
    4
    5
    6
    7
    int Socket (int family, int type, int protocol) {
    int n;
    if ((n = socket(family, type, protocol)) < 0) {
    err_sys("socket error");
    }
    return n;
    }
  • 这些包裹函数不见得多节省代码量,但是当我们讨论线程时,将会发现线程函数遇到错误时,并不设置标准Unix的errno变量,而是把errno的值作为函数返回值返回调用者。

  • 这意味着每次调用以pthread_开头的某个函数时,我们必须分配一个变量来存放函数的返回值,以便在调用err_sys前把errno变量设置成该值。

1.7

  • 通过填写一个网际套接字地址结构并调用bind函数,服务器的众所周知端口被捆绑到所创建的套接字

  • 指定IP地址为INADDR_ANY,这样要是服务器主机有多个网络接口,服务器就可以在任意网络接口上接收客户端连接

  • 调用listen函数把该套接字转换成一个监听套接字,这样来自客户的外来连接就可在该套接字上由内核接受

  • socket, bind和listen这3个调用步骤是任何TCP服务器准备所谓的监听描述符(listening descriptor)的正常步骤

  • 常值LISTENQ在unp.h头文件中定义。它指定系统内核允许在这个监听描述符上排队的最大客户连接数

1.8

  • 通常情况下,服务器进程在accept调用中被投入睡眠,等待某个客户连接的到达并被内核接受
  • TCP连接使用所谓的三路握手(three-way handshake)来建立连接。
    • 三次握手在socket API中内部实现,调用该接口时,不需要关心(Unix system programming,line:1807)
  • 握手完毕时accept返回,其返回值是一个称为已连接描述符(connected descriptor)的新描述符
  • 该描述符用于与新近连接的那个客户端通信。
  • accept为每个连接到本服务器的客户端返回一个新描述符

1.9

  • 服务器通过调用close关闭与客户的连接。

  • 该调用引发正常的TCP连接终止序列:每个方向上发送一个FIN,每个FIN又由各自的对端确认

  • 总结

    • 如果服务器需要用较多时间服务每个客户,那么必须以某种方式重叠对各个客户的服务,这种服务器被称为迭代服务器(iterative server)。因为对于每个客户它都迭代执行一次
    • 同时能处理多个客户的并发服务器(concurrent server)有多种编写技术
      • 最简单的技术是调用Unix的fork函数,为每个客户创建一个子进程
      • 其他技术包括使用线程代替fork,或在服务器启动时预先fork一定数量的子进程

2.0

  • 描述一个网络中各个协议层的常用方法是使用国际标准化组织(International Organization for Standardization, ISO)的计算机通信开发系统互连(open systems interconnection, OSI)模型。

  • 这是一个七层模型:

    • 应用程
    • 表示层
    • 会话层
    • 传输层
    • 网络层
    • 数据链路层
    • 物理层
  • 我们认为OSI模型的底下两层是随系统提供的设备驱动程序和网络硬件。

  • 通常情况下,除需要知道数据链路的某些特性外,不必太关心这两层的具体情况

  • 网络层,由IPv4和IPv6这两个协议处理

  • 可以选择的传输层有TCP或UDP

  • TCP与UDP,之间留有缝隙,表明网络应用绕过传输层直接使用IPv4和IPv6是有可能的,这就是所谓的原始套接字(raw socket)

  • OSI模型的顶上三层被合并成一层,称为应用层。

  • 这就是Web客户(浏览器),Telnet客户,Web服务器,FTP服务器和其他我们在使用的网络应用所在的层

  • 对于网际协议,OSI模型的顶上三层协议几乎没有区别

  • 本书讲述的套接字编程接口是从顶上三层(网际协议的应用程)进入传输层的接口

  • 本书的焦点是:如何使用套接字编写使用TCP或UDP的网络应用程序。

  • 为什么套接字提供的是从OSI模型的顶上三层进入传输层的接口?

  • 这样设计有两个理由

    • 一,顶上三层处理具体网络应用的所有细节,却对通信细节了解很少;底下四层对具体网络应用了解不多,却处理所有的通信细节:发送数据,等待确认,给无序到达的数据排序,计算并验证校验和,等等
    • 二,顶上三层通常构成所谓的用户进程(user process),底下四层却通常作为操作系统内核的一部分提供。Unix与其他现代操作系统都提供分隔用户进程和内核的机制。
    • 由此可见,第四层和第五层之间的接口是构建API的自然位置

2.1

  • POSIX(可移植操作系统接口),是Portable Operating System Inteface的首字母缩写。
  • 它并不是单个标准,而是由电气与电子工程学会(the Institute for Electrical and Electronics Engineers Inc)即IEEE开发的一系列标准
  • 具体可查看http://www.pasc.org/standing/sdll.html

传输层

1.1

  • TCP提供客户与服务器之间的连接。

  • TCP客户先与某个给定服务器建立一个连接,再跨该连接与那个服务器交换数据,然后终止这个连接

  • 三路握手

    • 服务器必须准备好接受外来的连接。
    • 这通常通过调用socket,bind和listen三个函数来完成,我们称之为被动打开(passive open)
    • 客户通过调用connect发起主动打开(active open)
  • 四次挥手

    • 某个应用进程首先调用close,我们称该端执行主动关闭(active close)
    • 接收到这个FIN的对端执行被动关闭(passive close)

1.2

  • TIME_WAIT状态有两个存在的理由:

    • 可靠地实现TCP全双工连接的终止
    • 允许老的重复分节在网络中消逝
  • 套接字对

    • 一个TCP连接的套接字对(socket pair),是一个定义该连接的两个端点的四元组
    • 本地IP地址,本地TCP端口号,外地IP地址,外地TCP端口号
  • 套接字对,唯一标识一个网络上的每个TCP连接

  • 标识每个端点的两个值(IP地址和端口号)通常称为一个,套接字

套接字编程

1.1

  • IPv4套接字地址结构,通常也称为“网际套接字地址结构”,它以sockaddr_in命名,定义在<netinet/in.h>头文件中

  • 通用套接字地址结构

    • 当作为一个参数传递进任何套接字函数时,套接字地址结构总是以引用形式(也就是以指向该结构的指针)来传递。
  • 在如何声明所传递指针的数据类型上存在一个问题。有了ANSI C后解决办法很简单:void *是通用的指针类型

  • 在1982年采取的办法是在<sys/socket.h>头文件中定义一个通用的套接字地址结构

1.2

  • 前面提到过,当往一个套接字函数传递一个套接字地址结构时,该结构总是以引用形式来传递,也就是说传递的是指向该结构的一个指针。
  • 该结构的长度也作为一个参数来传递,不过其传递方式取决于该结构的传递方向:是从进程到内核,还是从内核到进程
    • 从进程到内核传递套接字地址结构的函数有三个:bind,connect和sendto。指针和指针所指内容的大小都传递给内核,这样内核知道到底需从进程复制多少数据进来
    • 从内核到进程传递套接字地址结构的函数有四个:accept,recvfrom,getsockname和getpeername。把套接字地址结构大小这个参数从一个整数改为指向某个整数变量的指针,其原因在于:
      • 当函数被调用时,结构大小是一个值(value),它告诉内核该结构的大小,这样内核在写该结构时不至于越界
      • 当函数返回时,结构大小又是一个结果(result),它告诉进程,内核在该结构中究竟存储了多少信息
      • 这种类型的参数称为“值-结果(value-result)”参数

1.3

  • 考虑一个16位整数,它由2个字节组成。

  • 内存中存储这两个字节有两种方法

    • 一种是将低序字节存储在起始地址,这称为小端(little-endian)字节序
    • 另一种方法是将高序字节存储在起始地址,这称为大端(big-endian)字节序
  • 遗憾的是,这两种字节序之间没有标准可循,两种格式都有系统使用。

  • 我们把某个给定系统所用的字节序称为主机字节序(host byte order)

  • 网络协议指定一个网络字节序(network byte order)

  • 在每个TCP分节中都有16位的端口号和32位的IPv4地址。发送协议栈和接收协议栈必须就这些多字节字段各个字节的传送顺序达成一致。

  • 网际协议使用大端字节序来传送这些多字节整数

1.4

  • inet_aton, inet_addr和inet_ntoa在 点分十进制数串 与 它长度为32位的网络字节序二进制值 间转换IPv4地址

  • inet_ntoa函数将一个32位的网络字节序二进制IPv4地址转换成响应的点分十进制数串

  • 由该函数的返回值指向的字符串驻留在静态内存中。这意味着该函数是不可重入的

  • 该函数以一个结构而不是以指向该结构的一个指针作为其参数

    • “函数以结构为参数是罕见的,更常见的是以指向结构的指针为参数”

1.5

  • 字节流套接字(例如TCP套接字)上的 read 和 write 函数所表现的行为不同于通常的文件I/O。
  • 字节流套接字上调用 read 和 write 输入或输出的字节数可能比请求的数量少,然而这不是出错的状态。
  • 这个现象的原因在于内核中用于套接字的缓冲区可能已达到了极限。此时所需的是调用者再次调用 read 或 write函数,以输出或输出剩余的字节

基本TCP套接字编程

1.1

  • socket函数在成功时返回一个小的非负整数值,它与文件描述符类似,我们把它称为套接字描述符(socket descriptor),简称sockfd。

  • 为了得到这个套接字描述符,我们只是指定了协议族(IPv4,IPv6或Unix)和套接字类型(字节流,数据报或原始套接字)。

  • 我们并没有指定本地协议地址和远程协议地址

  • 对比 AF_XXX 和 PF_XXX

    • AF_ 前缀表示地址族,PF_ 前缀表示协议族
  • TCP客户用connect函数来建立与TCP服务器的连接

  • 客户在调用函数connect前不必非得调用bind函数,因为如果需要的话,内核会确定源IP地址,并选择一个临时端口作为源端口

  • bind函数把一个本地协议地址赋予一个套接字。对于网际网协议,协议地址是32为的IPv4地址或128位的Ipv6地址与16位的TCP或UDP端口号的组合

  • 服务器在启动时捆绑它们的众所周知端口。如果一个TCP客户或服务器未曾调用bind捆绑一个端口,当调用connect或listen时,内核就要为相应的套接字选择一个临时端口。

  • 让内核来选择临时端口对于TCP客户来说是正常的,除非应用需要一个预留端口;然而对于TCP服务器来说却极为罕见,因为服务器是通过它们的众所周知端口被大家认识的

    • 这个规则的例外是远程过程调用(Remote Procedure Call, RPC)服务器,它们通常就由内核为它们的监听套接字选择一个临时端口,而该端口随后通过RPC端口映射器进行注册。
    • 客户在connect这些服务器之间,必须与端口映射器联系以获取它们的临时端口,这种秦广也适用于使用UDP的RPC服务器
  • 捆绑(binding)操作涉及三个对象:套接字,地址及端口。

    • 其中套接字是捆绑的主体,地址和端口是捆绑在套接字上的客体
  • 如果指定端口号为0,那么内核就在bind被调用时选择一个临时端口。

  • 然而如果指定IP地址为通配地址,那么内核将等到套接字已连接(TCP)或已在套接字上发出数据包(UDP)时才选择一个本地IP地址

  • 对于IPv4来说,通配地址由常值INADDR_ANY来指定,其值一般为0

  • 如果让内核来为套接字选择一个临时端口号,那么必须注意,函数bind并不返回所选择的值。为了得到内核所选择的这个临时值,必须调用函数getsockname来返回协议地址

  • 到达(arriving)和接收(received)

    • 这两个修饰词,它们具有相同的含义,只是视角不同而已
    • 譬如说一个分组的到达接口和接收接口指的是同一个接口,前者在接收主机以外看待这个接口,后者在接收主机以内看待这个接口

1.2

  • listen函数仅由TCP服务器调用,它做两件事情

    • 当socket函数创建一个套接字时,它被假设为一个主动套接字,也就是说,它是一个将调用connect发起连接的客户套接字。listen函数把一个未连接的套接字转换成一个被动套接字,指示内核应接收指向该套接字的连接请求。调用listen导致套接字从CLOSE状态转换到LISTEN状态
    • 第二个参数规定了内核应该为相应套接字排队的最大连接个数
  • 为了理解第二个参数backlog参数,我们必须认识到内核为任何一个给定的监听套接字维护两个队列:

    • 未完成连接队列(incomplete connection queue),每个这样的SYN分节对应其中一项:已由某个客户发出并到达服务器,而服务器正在等待完成相应的TCP三路握手过程。这些套接字处于SYN_RCVD状态
    • 已完成连接队列(completed connection queue),每个已完成TCP三路握手过程的客户对应其中一项,这些套接字处于ESTABLISHED状态

1.3

  • accept函数由TCP服务器调用,用于从已完成连接队列队头返回下一个已完成连接。如果已完成连接队列为空,那么进程被投入睡眠(假定套接字为默认的阻塞方式)

  • 声明

    • #include <sys/socket.h>
    • int accept (int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);
  • 参数cliaddr和addrlen用来返回已连接的对端进程(客户)的协议地址。addrlen是值-结果参数;调用前,我们将由*addrlen所引用的整数值置为由cliaddr所指的套接字地质结构的长度,返回时,该整数值即为由内核存放在该套接字地址结构内的确切字节数

  • 如果accept成功,那么其返回值是由内核自动生成的一个全新描述符,代表与所返回客户的TCP连接

  • 在讨论accept函数时,我们称它的第一个参数为监听套接字(listening socket)描述符(由socket创建,随后用作bind和listen的第一个参数的描述符),称它的返回值为已连接套接字(connect socket)描述符。

  • 区分这两个套接字非常重要!

    • 一个服务器通常仅仅创建一个监听套接字,它在该服务器的生命期内一直存在。
    • 内核为每个由服务器进程接受的客户连接创建一个已连接套接字(也就是说对于它的TCP三路握手过程已经完成)。当服务器完成对某个给定客户的服务时,相应的已连接套接字就被关闭
  • accept函数最多返回三个值

    • 一个既可能是新套接字描述符也可能是出错指示的整数
    • 客户进程的协议地址(由cliaddr指针所指)以及该地址的大小(由addrlen指针所指)。如果我们对返回客户协议地址不感兴趣,那么可以把cliaddr和addrlen均置为空指针

1.4

  • 在阐述如何编写并发服务器程序之前,我们必须首先介绍一下Unix的fork函数。

  • 该函数(包括有些系统可能提供的它的各种变体),是Unix中派生新进程的唯一方法。

  • 如果以前从未接触过该函数,那么理解fork最困难之处在于调用它一次,它却返回两次。它在调用进程(称为父进程)中返回一次,返回值是新派生进程(称为子进程)的进程ID号;在子进程又返回一次,返回值为0.

  • 因此,返回值本身告知当前进程是子进程还是父进程。

  • fork在子进程返回 0 而不是父进程的 进程ID 的原因在于:

    • 任何子进程只有一个父进程,而且子进程总是可以通过调用 getppid 取得父进程的进程ID。
    • 相反,父进程可以有许多子进程,而且无法获取各个子进程的进程ID。如果父进程想要跟踪所有子进程的进程ID,那么它必须记录每次调用 fork 的返回值
  • 父进程中调用 fork 之前打开的所有描述符在 fork 返回之后由子进程分享。

  • 我们将看到网络服务器利用了这个特性:

    • 父进程调用 accept 之后调用fork。所接受的已连接套接字随后就在父进程与子进程之间共享。
    • 通常情况下,子进程接着读写这个已连接套接字,父进程则关闭这个已连接套接字
  • fork 有两个典型用法

    • 一个进程创建一个自身的副本,这样每个副本都可以在另一个副本执行其他任务的同时处理自己的某个操作。这是网络服务器的典型用法
    • 一个进程想要执行另一个程序。既然创建新进程的唯一办法是调用 fork,该进程于是首先调用 fork 创建一个自身的副本,然后其中一个副本(通常为子进程)调用 exec 把自身替换成新的程序。这是诸如 shell 之类程序的典型用法
  • 存放在硬盘上的可执行程序文件能够被 Unix 执行的唯一方法是:由一个现有进程调用六个 exec 函数中的某一个。(当这六个函数中是哪一个被调用并不重要时,我们往往把它们统称为 exec 函数。)

  • exec 把当前进程映像替换成新的程序文件,而且该新程序通常从 main 函数开始执行。进程ID并不改变。我们称调用 exec 的进程为调用进程(calling process),称新执行的程序为新程序(new program)

  • 这六个 exec 函数之间的区别在于:

    • 待执行的程序文件是由文件名(filename)还是由路径名(pathname)指定;
    • 新程序的参数是一一列出还是由一个指针数组来引用;
    • 把调用进程的环境传递给新程序还是给新程序指定新的环境。
  • 这些函数只在出错时才返回到调用者。否则,控制将被传递给新程序的起始点,通常就是 main 函数

  • 这六个函数,一般来说,只有 execve 是内核中的系统调用,其他五个都是调用 execve 的库函数(91页,关系图)

1.5

  • 当服务一个客户请求可能花费较长时间时,我们并不希望整个服务器被单个客户长期占用,而是希望同时服务多个客户。

  • Unix中编写并发服务器程序最简单的办法就是 fork 一个子进程来服务每个客户

  • 当一个连接建立时,accept 返回,服务器接着调用 fork,然后由子进程服务客户(通过已连接套接字 connfd),父进程则等待另一个连接(通过监听套接字 listenfd)。

  • 既然新的客户由子进程提供服务,父进程就关闭已连接套接字

  • 对一个TCP套接字调用 close 会导致发送一个 FIN,随后是正常的TCP连接终止序列。为什么父进程对 connfd 调用 close 没有终止它与客户的连接呢?

    • 为了便于理解,我们必须知道每个文件或套接字都有一个引用计数。引用计数在文件表项中维护,它是当前打开着的引用该文件或套接字的描述符的个数。
    • socket 返回后与 listenfd 关联的文件表项的引用计数值为1。 accept 返回后与 connfd 关联的文件表项的引用计数也为1。
    • 然后 fork 返回后,这两个描述符就在父进程与子进程间共享(也就是被复制),因此与这两个套接字相关联的文件表项各自的访问计数值均为2.这么一来,当父进程关闭connfd时,它只是把相应的引用计数值从2减为1.
    • 该套接字真正的清理和资源释放要等到其引用计数值到达0时才发生。这会在稍后子进程也关闭connfd时发生。

1.6

  • 通常的Unix close函数也用来关闭套接字,并终止TCP连接

  • close一个TCP套接字的默认行为,是把该套接字标记成已关闭,然后立即返回到调用进程。

  • 该套接字描述符不能再由调用进程使用,也就是说它不能再作为read或write的第一个参数。

  • 然而TCP将尝试发送已排队等待发送到对端的任何数据,发送完毕后发生的是正常的TCP连接终止序列

  • 如果我们确实想在某个TCP连接上发送一个FIN,那么可以改用shutdown函数以代替close

  • 我们还得清楚,如果父进程对每个由 accept 返回的已连接套接字都不调用close,那么并发服务器中将会发生什么?

    • 首先,父进程最终将耗尽可用描述符,因为任何进程再任何时刻可拥有的打开着的描述符通常是有限制的
    • 不过更重要的是,没有一个客户连接会被终止。当子进程关闭已连接套接字时,它的引用计数值由2递减为1且保持为1,因为父进程永不关闭任何已连接套接字。这将妨碍TCP连接终止序列的发生,导致连接一直打开着

1.7

  • getsockname 和 getpeername两个函数,

    • 或者返回与某个套接字关联的本地协议地址(getsockname),
    • 或者返回某个套接字关联的外地协议地址(getpeername)
  • 需要注意的是,这两个函数的最后一个参数都是值-结果参数。这就是说,这两个函数都得装填由localaddr或peeraddr指针所指向的套接字地址结构

  • 需要这两个函数的理由如下:

    • 在一个没有调用bind的TCP客户上,connect成功返回之后,getsockname用于返回由内核赋予该连接的本地IP地址和本地端口号
    • 在以端口号0调用bind(告知内核去选择本地端口号)后,getsockname用于返回由内核赋予的本地端口号
    • getsockname可用于获取某个套接字的地址族
    • 在一个以通配IP地址调用bind的TCP服务器上,与某个客户的连接一旦建立(accept成功返回),getsockname就可以用于返回由内核赋予该连接的本地IP地址。在这样的调用中,套接字描述符必须是已连接套接字的描述符,而不是监听套接字的描述符
    • 当一个服务器是由调用过accept的某个进程通过调用exec执行程序时,它能够获取客户身份的唯一途径便是调用getpeername

TCP客户/服务器程序示例

1.1

  • 信号(signal),就是告知某个进程发生了某个事件的通知,有时也称为软件中断(software interrupt)。

  • 信号通常是异步发生的,也就是说进程预先不知道信号的准确发生时刻

  • 信号可以:

    • 由一个进程发给另一个进程(或自身)
    • 由内核发给某个进程
  • 每个信号都有一个与之关联的处置(disposition),也称为行为(action)。我们通过调用sigaction函数来设定一个信号的处置,并有三种选择

    • 我们可以提供一个函数,只要有特定信号发生它就被调用。这样的函数称为信号处理函数(signal handler),这种行为称为捕获(catching)信号。有两个信号不能被捕获,它们是SIGKILL和SIGSTOP。信号处理函数由信号值这个单一的整数参数来调用,且没有返回值,其原型为void handler(int signo);(信号处理函数,也称为信号处理程序,这是相对于main函数所在的主程序而言的)。对于大多数信号来说,调用sigaction函数并指定信号发生时所调用的函数就是捕获信号所需做的全部工作。
    • 我们可以把某个信号的处置设定为SIG_IGN来忽略(ignore)它。SIGKILL和SIGSTOP这两个信号不能被忽略
    • 我们可以把某个信号的处置设定SIG_DFL来启用它的默认处置。默认处置,通常是收到信号后终止进程,其中某些信号还在当前工作目录产生一个进程的核心映像(core image,也称为内存影像)

1.2

  • 设置僵死(zombie)状态的目的,是维护子进程的信息,以便父进程在以后某个时候获取。这些信息包括子进程的进程ID,终止状态以及资源利用信息(CPU时间,内存使用量等)

  • 如果一个进程终止,而该进程有子进程处于僵死状态,那么它的所有僵死子进程的父进程ID将被重置为1(init进程)。继承这些子进程的init进程将清理它们(也就是说init进程将wait它们,从而去除它们的僵死状态)

  • 我们显然不愿意留存僵死进程。它们占用内核中的空间,最终可能导致我们耗尽进程资源

  • 无论何时我们fork子进程都得wait它们,以防它们变成僵死进程。为此我们建立一个俘获SIGCHLD信号的信号处理函数,在函数体中我们调用wait

1.3

  • 我们用术语慢系统调用(slow system call)描述过accept函数,该术语也适用于那些可能永远阻塞的系统调用。永远阻塞的系统调用是指调用有可能永远无法返回,多数网络支持函数都属于这一类。

  • 一个值得注意的例外是磁盘I/O,它们一般都会返回到调用者(假设没有灾难性的硬件故障)

  • 适用于慢系统调用的基本规则是:当阻塞于某个慢系统调用的一个进程捕获某个信号且相应信号处理函数返回时,该系统调用可能返回一个EINTR错误

  • 本节的目的是示范我们在网络编程时可能会遇到的三种情况

    • 当fork子进程时,必须捕获SIGCHLD信号
    • 当捕获信号时,必须处理被中断的系统调用
    • SIGCHLD的信号处理函数必须正确编写,应使用waitpid函数以免留下僵死进程

1.4

  • 当我们的服务器进程正在运行时,服务器主机被操作员关机将会发生什么?

  • Unix系统关机时,init进程通常先给所有进程发送SIGTERM信号(该信号可被捕获),等待一段固定的时间(往往在5到20秒之间),然后给所有仍在运行的进程发送SIGKILL信号(该信号不能被捕获)。这么做留给所有运行的进程一小段时间来清楚和终止。如果我们不捕获SIGTERM信号并终止,我们的服务器将由SIGKILL信号终止

  • 如果我们忽略SIGTERM信号,我们的服务器将由SIGKILL信号终止。SIGTERM信号的默认处置就是终止进程,因此要是我们不捕获它(也不忽略它),那么起作用的是它的默认处置,我们的服务器将被SIGTERM信号终止,SIGKILL信号不可能再发送给他

1.5

  • 一般来说,我们必须关心在客户和服务器之间进行交换的数据的格式

I/O复用:select和poll函数

1.1

  • 进程需要一种预先告知内核的能力,使得内核一旦发现进程指定的一个或多个I/O条件就绪(也就是说输入已准备好被读取,或者描述符已能承接更多的输出),它就通知进程。这个能力称为I/O复用(I/O multiplexing),是由select和poll这两个函数支持的

  • 在介绍select和poll这两个函数之前,我们需要回顾整体,查看Unix下可用的5种I/O模型的基本区别:

    • 阻塞式I/O
    • 非阻塞式I/O
    • I/O复用(select和poll)
    • 信号驱动式I/O(SIGIO)
    • 异步I/O(POSIX的aio_系列函数)
  • 一个输入操作通常包括两个不同的阶段:

    • 等待数据准备好
    • 从内核向进程复制数据
  • 对于一个套接字上的输入操作,

    • 第一步通常涉及等待数据从网络中到达。当所等待分组到达时,它被复制到内核中的某个缓冲区
    • 第二步就是把数据从内核缓冲区复制到应用进程缓冲区
  • 当一个应用进程像这样对一个非阻塞描述符循环调用recvfrom时,我们称之为轮询(polling)。

  • 应用进程持续轮询内核,以查看某个操作是否就绪。这么做往往耗费大量CPU时间

1.2

  • 有了I/O复用(I/O multiplexing),我们就可以调用select或poll,阻塞在这两个系统调用中的某一个之上,而不是阻塞在真正的I/O系统调用上

  • 我们阻塞于select调用,等待数据包套接字变为可读。当select返回套接字可读这一条件时,我们调用recvfrom把所读数据包复制到应用进程缓冲区

  • 我们也可以用信号,让内核在描述符就绪时发送SIGIO信号通知我们。我们称这种模型为信号驱动式I/O(signal-driven I/O)

  • 异步I/O(asynchronous I/O)由POSIX规范定义

  • 这些函数的工作机制是:告诉内核启动某个操作,并让内核在整个操作(包括将数据从内核复制到我们自己的缓冲区)完成后通知我们。

  • 这种模型与信号驱动模型主要区别在于

    • 信号驱动式I/O,是由内核通知我们何时可以启动一个I/O操作
    • 而异步I/O模型,是由内核通知我们I/O操作何时完成

1.3

  • POSIX把同步I/O和异步I/O两个术语定义如下:

    • 同步I/O操作(synchronous I/O opetation),导致请求进程阻塞,直到I/O操作完成;
    • 异步I/O操作(asynchronous I/O opetation),不导致请求进程阻塞
  • 根据上述定义,前四种模型:阻塞式I/O模型,非阻塞式I/O模型,I/O复用模型和信号驱动式I/O模型都是同步I/O模型,因为其中真正的I/O操作(recvfrom)将阻塞进程。

  • 只有异步I/O模型与POSIX定义的异步I/O相匹配

1.4

  • select函数,允许进程指示内核等待多个事件中的任何一个发生,并只在有一个或多个事件发生或经历一段指定的事件后唤醒它

  • 声明:

    • #include <sys/select.h>
    • #include <sys/time.h>
    • int select (int maxfdpl, fd_set *readset, fd_set *writeset, fd_set *execptset, const struct timeval *timeout);
  • 参数timeout,它告知内核等待所指定描述符中的任何一个就绪可花多长时间。其中timeval结构用于指定这段时间的秒数和微秒数

  • 这个参数有以下三种可能:

    • 永远等待下去:仅在有一个描述符准备好I/O时才返回。为此,我们把该参数设置为空指针
    • 等待一段固定时间:在有一个描述符准备好I/O时返回,但是不超过由该参数所指向的timeval结构中指定的秒数和微秒数
    • 根本不等待:检查描述符后立即返回,这称为轮询(polling)。为此,该参数必须指向一个timeval结构,而且其中的定时器值(由该结构指定的秒数和微秒数)必须为0.
  • 中间的三个参数readset,writeset和execptset指定我们要让内核测试读,写和异常条件的描述符。目前支持的异常条件只有两个:

    • 某个套接字的外带数据的到达
    • 某个已置为分组模式的伪终端存在可从其主端读取的控制状态信息
  • 如何给这三个参数中的每一个参数指定一个或多个描述符值是一个设计上的问题。select使用描述符集,通常是一个整数数组,其中每个整数中的每一位对应一个描述符

  • select函数的中间三个参数readset,writeset和exceptset中,如果我们对某一个的条件不感兴趣,就可以把它设为空指针

  • maxfdpl参数指定待测试的描述符个数,它的值是待测试的最大描述符加1(因此我们把该参数命名为maxfdp1)

  • select函数修改由指针readset,writeset和exceptset所指向的描述符集,因而这三个参数都是值-结果参数

  • 调用该函数时,我们指定所关心的描述符的值,该函数返回时,结果将指示哪些描述符已就绪

  • 使用select时最常见的两个编程错误是

    • 忘了对最大描述符加1
    • 忘了描述符集是值-结果参数。

1.5

  • 终止网络连接的通常方法是调用close函数。不过close有两个限制,却可以使用shutdown来避免

    • close把描述符的引用计数减一,仅在该计数变为0时才关闭套接字。使用shutdown可以不管引用计数就激发TCP的正常连接终止序列
    • close终止读和写两个方向的数据传送。既然TCP连接是全双工的,有时候我们需要告知对端我们已经完成了数据发送,即使对端仍有数据要发送给我们
  • 声明

    • #include <sys/socket.h>
    • int shutdown (int sockfd, int howto);
  • 该函数的行为依赖于howto参数的值

    • SHUT_RD,关闭连接的读这一半 – 套接字中不再有数据可接收,而且套接字接受缓冲区中的现有数据都被丢弃
    • SHUT_WR,关闭连接的写这一半 – 对于TCP套接字,这称为半关闭(half-close)
    • SHUT_RDWR,连接的读半部和写半部都关闭 – 这与调用shutdown两次等效:第一次调用指定SHUT_RD,第二次调用指定SHUT_WR

1.6

  • pselect函数是由POSIX发明的,如今有许多Unix变种支持它
  • pselect相对于通常的select有两个变化
    • pselect使用timespec结构,而不使用timeval结构。timespec结构是POSIX的又一个发明
    • pselect函数增加了第六个参数:一个指向信号掩码的指针。

1.7

  • poll提供的功能与select类似,不过在处理流设备时,它能够提供额外的信息
  • 声明
    • #include <poll.h>
    • int poll (struct pollfd *fdarray, unsigned long nfds,int timeout);
  • 第一个参数是指向一个结构数组第一个元素的指针。每个数组元素都是一个pollfd结构,用于指定测试某个给定描述符fd的条件
  • timeout参数指定poll函数返回前等待多长时间。它是一个指定应等待毫秒数的正值

套接字选项

1.1

  • 有很多方法来获取和设置影响套接字的选项

    • getsockopt和setsockopt函数
    • fcntl函数
    • ioctl函数
  • 套接字选项粗分为两大基本类型:

    • 一是,启用或禁止某个特性的二元选项(称为标志选项)
    • 二是,取得并返回我们可以设置或检查的特定值的选项(称为值选项)

1.2

  • 与代表”file control”(文件控制)的名字相符,fcntl函数可执行各种描述符控制操作

  • 声明:

    • #include <fcntl.h>
    • int fcntl (int fd, int cmd, ... /*int arg*/);
  • 每种描述符(包括套接字描述符)都有一组由F_GETFL命令获取或由F_SETFL命令设置的文件标志。

  • 其中影响套接字描述符的两个标志是:

    • O_NONBLOCK – 非阻塞式I/O
    • O_ASYNC – 信号驱动式I/O
  • 设置某个文件状态标志的唯一正确的方法是:先取得当前标志,与新标志逻辑或后再设置标志

名字与地址转换

1.1

  • 到目前为止,本书中所有例子都用数值地址来表示主机,用数值端口号来标识服务器,然而出于许多理由,我们应该使用名字而不是数值

  • 名字比较容易记住,数值地址可以变动而名字保持不变,随着往IPv6上转移,数值地址变得相当长,手工键入数值地址更易出错

  • gethostbyname和gethostbyaddr在主机名字与IPv4地址之间转换

  • getservbyname和getservbyport在服务名字和端口号之间进行转换

  • getaddrinfo和getnameinfo,分别用于主机名字和IP地址之间以及服务名字和端口号之间的转换

1.2

  • 域名系统(Domain Name System, DNS),主要用于主机名字和IP地址之间的映射

  • DNS中的条目称为资源记录(resource record, RR)

  • 每个组织机构往往运行一个或多个名字服务器(name server),它们通常就是所谓的BIND(Berkeley Internet Name Domain)程序

  • 诸如我们在本书中编写的客户和服务器等应用程序通过调用称为解析器(resolver)的函数库中的函数接触DNS服务器

  • 常见的解析器函数是gethostbyname和gethostbyaddr,前者把主机名映射成IPv4地址,后者则执行相反的映射

1.3

  • 查找主机名最基本的函数是gethostbyname。如果调用成功,它就返回一个指向hostent结构的指针,该结构中含有所查找主机的所有IPv4地址。这个函数的局限是只能返回IPv4地址,而getaddrinfo函数能够同时处理IPv4地址和IPv6地址

  • 声明:

    • #include <netdb.h>
    • struct hostent *gethostbyname (const char *hostname);
  • gethostbyaddr函数试图由一个二进制的IP地址找到相应的主机名,与gethostbyname的行为刚好相反

  • 声明:

    • #include <netdb.h>
    • struct hostent *gethostbyaddr(const char *addr, socklent len, int family);

1.3

  • tcp_listen执行TCP服务器的通常步骤:创建一个TCP套接字,给他捆绑服务器的众所周知端口,并允许接受外来的连接请求
  • 实现步骤:
    • 调用getaddrinfo
    • 创建套接字并给他捆绑地址

守护进程和inetd超级服务器

1.1

  • 守护进程(daemon),是在后台运行且不与任何控制终端关联的进程。Unix系统通常有很多守护进程在后台运行(约在20到50个的量级),执行不同的管理任务。

  • 守护进程没有控制终端通常源于它们由系统初始化脚本启动。然而守护进程也可能从某个终端由用户在shell提示符下键入命令启动,这样的守护进程必须亲自脱离与控制终端的关联,从而避免与作业控制,终端会话管理,终端产生信号等发生任何不期望的交互,也可以避免在后台运行的守护进程非预期地输出到终端。

  • 守护进程有多种启动方法:

    • 在系统启动阶段,许多守护进程由系统初始化脚本启动。这些脚本通常位于/etc/目录或以/etc/rc开头的某个目录中,它们的具体位置和内容却是实现相关的。由这些脚本启动的守护进程一开始时拥有超级用户特权。有若干个网络服务器通常从这些脚本启动:inetd超级服务器,Web服务器,邮件服务器
    • 许多网络服务器由inetd超级服务器启动。inetd自身由上一条中的某个脚本启动。inetd监听网络请求(Telnet, FTP)等,每当有一个请求到达时,启动相应的实际服务器(Telnet服务器,FTP服务器等);
    • cron守护进程按照规则定期执行一些程序,而由它启动执行的程序同样作为守护进程运行。cron自身由第一条启动方法中的某个脚本启动
    • at命令用于指定将来某个时刻的程序执行。这些程序的执行时刻到来时,通常由cron守护进程启动执行它们,因此这些程序同样作为守护进程运行
    • 守护进程还可以从用户终端或在前台后在后台启动。
  • 因为守护进程没有控制终端,所以当有事发生时它们得有输出消息的某种方法可用,而这些消息既可能是普通的通告性消息,也可能是需由系统管理员处理的紧急事件消息。

  • syslog函数,是输出这些消息的标准方法,它把这些消息发送给syslogd守护进程

1.2

  • 既然守护进程没有控制终端,它们就不能把消息fprintf到stderr上。从守护进程中登记消息的常用技巧就是调用syslog函数

  • 声明:

    • #include <syslog.h>
    • void syslog(int priority, const char *message, ...);
  • 本函数的priority参数是级别(level)和设施(fcility)两者的组合

    • 日志消息的level可从0到7,它们是按从高到低的顺序排列的。如果发送者未指定level值,那就默认为LOG_NOTICE
      • LOG_EMERG – 0 – 系统不可用(最高优先级)
      • LOG_ALERT – 1 – 必须立即采取行动
      • LOG_CRIT – 2 – 临界条件
      • LOG_ERR – 3 – 出错条件
      • LOG_WARNING– 4 – 警告条件
      • LOG_NOTICE – 5 – 正常然而重要的条件(默认值)
      • LOG_INFO – 6 – 通告消息
      • LOG_DEBUG – 7 – 调试级消息(最低优先级)
    • 日志消息还包含一个用于标识消息发送进程类型的facility。如果发送者未指定facility值,那就默认为LOG_USER
  • facility和level的目的在于:允许在/etc/syslog.conf文件中统一配置来自同一给定设施的所有消息,或者统一配置具有相同级别的所有消息

  • 当syslog被应用进程首次调用时,它创建一个Unix域数据报套接字,然后调用connect连接到由syslogd守护进程创建的Unix域数据报套接字的众所周知路径名(例如/var/run/log)。这个套接字一直保持打开,直到进程终止为止。

  • 作为替换,进程也可以调用openlog和closelog:openlog可以在首次调用syslog前调用,closelog可以在应用进程不再需要发送日志消息时调用

  • 声明:

    • #include <syslog.h>
    • void openlog(const char *ident, int options, int facility);
    • void closelog(void);
  • ident参数,是一个由syslog冠于每个日志消息之前的字符串。它的值通常是程序名

  • options参数,由一个或多个常值的逻辑或构成

    • LOG_CONS – 若无法发送到syslogd守护进程,则登记到控制台
    • LOG_NDELAY– 不延迟打开,立即创建套接字
    • LOG_PERROR– 既发送到syslogd守护进程,又登记到标准错误输出
    • LOG_PID – 随每个日志消息登记进程ID
  • openlod的facility参数,为没有指定设施的后续syslog调用指定一个默认值

1.3

  • 守护进程,是在后台运行并独立于所有终端控制的进程。许多网络服务器作为守护进程运行。守护进程产生的所有输出通常通过调用syslog函数发送给syslogd守护进程。系统管理员可根据发送消息的守护进程以及消息的严重级别,完全控制这些消息的处理方式
  • 启动任意一个程序并让它作为守护进程运行需要以下步骤:
    • 调用fork以转到后台运行
    • 调用setsid建立一个新的POSIX会话并成为会话头进程,
    • 再次fork以避免无意中获得新的控制终端,改变工作目录和文件创建模式掩码
    • 最后关闭所有非必要的描述符

1.4

  • recv和send函数,类似标准的read和write函数,不过需要一个额外的参数
  • 声明:
    • #include <sys/socket.h>
    • ssize_t recv (int sockfd, void *buff, size_t nbytes, int flags);
    • ssize_t send (int sockfd, const void *buff, size_t nbytes, int flags);
  • recv和send的前三个参数等同于read和write的三个参数,flags参数的值或为0.常见的逻辑或
    • MSG_DONTROUTE – 绕过路由表查找,本标志告知内核目的主机在某个直接连接的本地网络上,因而无需执行路由表查找 – send
    • MSG_DONTWAIT – 仅本操作非阻塞,本标志在无需打开相应套接字的非阻塞标志的前提下,把单个I/O操作临时指定为非阻塞,接着执行I/O操作,然后关闭非阻塞标志 – recv,send
    • MSG_OOB – 发送或接收外带数据 ,对于send,本标志指明即将发送带外数据;对于recv,本标志指明即将读入的是带外数据而不是普通数据 – recv,send
    • MSG_PEEK – 窥看外来消息,本标志适用于recv和recvfrom,它允许我们查看已可读取的数据,而且系统不在recv或recvfrom返回后丢弃这些数据 – recv
    • MSG_WAITALL – 等待所有数据,它告知内核不要再尚未读入请求数目的字节之前让一个读操作返回 – recv

1.5

  • readv和writev函数,类似read和write,不过readv和writev允许单个系统调用读入到或写出自一个或多个缓冲区。这些操作分别称为分散读(scatter read)和集中写(gather write),因为读操作的输入数据被分散到多个应用缓冲区中,而来自多个应用缓冲区的输出数据则被集中提供给单个写操作
  • 声明:
    • #include <sys/uio.h>
    • ssize_t readv (int filedes, const struct iovec *iov, int iovcnt);
    • ssize_t writev (int filedes, const struct iovec *iov, int iovcnt);
  • readv和writev这两个函数可用于任何描述符,而不仅限于套接字。
  • 另外,writev是一个原子操作,意味着对于一个基于记录的协议(例如UDP)而言,一次writev调用只产生单个UDP数据包

1.6

  • recvmsg和sendmsg,这两个函数是最通用的I/O函数。
  • 实际上,我们可以把所有read,readv,recv和recvfrom调用替换成recvmsg调用。类似的,各种输出函数调用也可以替换成sendmsg调用
  • 声明:
    • #include <sys/socket.h>
    • ssize_t recvmsg (int sockfd, struct msghdr *msg, int flags);
    • ssize_t sendmsg (int sockfd, struct msghdr *msg, int flags);
  • 这两个函数把大部分参数封装到一个msghdr结构中:
    1
    2
    3
    4
    5
    6
    7
    8
    struct msghdr {
    void *msg_name; /* protocol address */
    socklen_t msg_namelen; /* size of protocol address */
    struct iovec *msg_iov; /* scatter/gather array */
    int msg_iovlen; /* elements in msg_iov */
    void *msg_control; /* ancillary data (cmsghdr struct) */
    socklen_t msg_controllen; /* flags returned by recvmsg() */
    };
    • msg_name和msg_namelen,这两个成员用于套接字未连接的场合(例如未连接UDP套接字)。它们类似recvfrom和sendto的第五个和第六个参数:msg_name指向一个套接字地址结构,调用者再其中存放接收者(对于sendmsg调用)或发送者(对于recvmsg调用)的协议地址。如果无需指明协议地址,msg_name应置为空指针。msg_namelen对于sendmsg是一个值参数,对于recvmsg却是一个值-结果参数
    • msg_iov和msg_iovlen,这两个成员指定输入或输出缓冲区数组(即iovec结构数组),类似readv或writev的第二个和第三个参数
    • msg_control和msg_controllen,这两个成员指定可选的辅助数据的位置和大小。msg_controllen对于recvmsg是一个值-结果参数

1.7

  • 辅助数据(ancillary data),可通过调用sendmsg和recvmsg这两个函数,使用msghdr结构中的msg_control和msg_controllen这两个成员发送和接收。

  • 辅助数据的另一个称谓,是控制信息(control information)

  • 辅助数据由一个或多个辅助数据对象(ancillary data object)构成,每个对象以一个定义在头文件<sys/socket.h>中的cmsghdr结构开头

  • 声明:

    1
    2
    3
    4
    5
    struct cmsghdr {
    socklen_t cmsg_len; /* length in bytes, including this structure */
    int cmsg_level; /* originating protocol */
    int cmsg_type; /* protocol-specific type */
    };
  • msg_control指向第一个辅助数据对象,辅助数据的总长度则由msg_controllen指定。每个对象开头都是一个描述该对象的cmsghdr结构。

1.8

  • 到目前为止的所有例子中,我们一直使用也称为Unix I/O – 包括read,write这两个函数及它们的变体(recv,send等等)的函数执行I/O。这些函数围绕描述符(descriptor)工作,通常作为Unix内核中的系统调用实现

  • 执行I/O的另一个方法是使用标准I/O函数库(standard I/O library)。这个函数库由ANSI C标准规范,意在便于移植到支持ANSI C的非Unix系统上

  • 标准I/O函数库执行以下三类缓冲:

    • 完全缓冲(fully buffering),意味着只在出现下列情况时才发生I/O:缓冲区满,进程显示调用fflush,或进程调用exit终止自身。标准I/O缓冲区的通常大小为8192字节
    • 行缓冲(line buffering),意味着只在出现下列情况时才发生I/O:碰到一个换行符,进程调用fflush,或进程调用exit终止自身
    • 不缓冲(unbuffering),意味着每次调用标准I/O输出函数都发生I/O
  • 标准I/O函数库的大多数Unix实现使用如下规则:

    • 标准错误输出总是不缓冲
    • 标准输入和标准输出完全缓冲,除非它们指代终端设备(这种情况下它们行缓冲)
    • 所有其他I/O流都是完全缓冲,除非它们指代终端设备(这种情况下它们行缓冲)
  • 套接字不是终端设备,输出流是完全缓冲的。存在的问题有两个解决办法:

    • 第一个办法是通过调用setvbuf迫使这个输出流变为行缓冲
    • 第二个办法是在每次调用fputs之后通过调用fflush强制输出每个回射行
  • 然而在现实使用中,这两种办法都易于犯错。大多数情况下,最好的解决办法是:彻底避免在套接字上使用标准I/O函数库

1.9

  • 在套接字操作上设置时间限制的方法有三个:

    • 使用alarm函数和SIGALRM信号;
    • 使用由select提供的时间限制
    • 使用较新的SO_RCVTIMEO和SO_SNDTIMEO套接字选项
  • 第一个方法易于使用,不过涉及信号处理,而信号处理可能导致竞争条件。使用select意味着我们阻塞在指定过时间限制的这个函数上,而不是阻塞在read,write或connect调用上。第三个方法也易于使用,不过并非所有实现都提供

  • recvmsg和sendmsg是所提供的五组I/O函数中最为通用的。它们组合了如下能力:

    • 指定MSG_XXX标志(出自recv和send)
    • 返回或指定对端的协议地址(出自recvfrom和sendto)
    • 使用多个缓冲区(出自readv和writev)
    • 此外,还增加了两个新的特性:给应用进程返回标志,接收或发送辅助数据
  • 辅助数据,由一个或多个辅助数据对象构成,每个对象都以一个cmsghdr结构打头,它指定数据的长度,协议级别及类型。五个以CMSG_打头的函数可用于构建和分析辅助数据

  • C标准I/O函数库也可以用在套接字上,不过这么做将在已经由TCP提供的缓冲级别之上新增一级缓冲。实际上,对由标准I/O函数库执行的缓冲缺乏了解是使用这个函数库最常见的问题。既然套接字不是终端设备,这个潜在问题的常用解决办法就是把标准I/O流设置成不缓冲,或者干脆不要在套接字上使用标准I/O

Unix域协议

1.1

  • Unix域协议并不是一个实际的协议族,而是在单个主机上执行客户/服务器通信的一种方法,所用API就是在不同主机上执行客户/服务器通信所用的API(套接字APT)
  • Unix域提供两类套接字:字节流套接字(类似TCP)和数据包套接字(类似UDP)

1.2

  • socketpair函数,创建两个随后连接起来的套接字。本函数仅适用于Unix域套接字

  • 声明:

    • #include <sys/socket.h>
    • int socketpair (int family, int type, int protocol, int sockfd[2]);
  • family参数,必须为AF_LOCAL,

  • protocol参数,必须为0

  • type参数,既可以是SOCK_STREAM,也可以是SOCK_DGRAM

  • 新创建的两个套接字描述符作为sockfd[0]和sockfd[1]返回

  • 本函数类似Unix的pipe函数,会返回两个彼此连接的描述符。这样创建的两个套接字不曾命名,也就是说其中没有涉及隐式的bind调用

  • 同一个主机上客户和服务器之间的描述符传递是一个非常有用的技术,它通过Unix域套接字发生

非阻塞式I/O

1.1

  • 套接字的默认状态是阻塞的。这就意味着当发出一个不能立即完成的套接字调用时,其进程将被投入睡眠,等待响应操作完成。可能阻塞的套接字调用可分为以下四类:
    • 输入操作,包括read,readv,recv,recvfrom和recvmsg共五个函数。如果某个进程对一个阻塞的TCP套接字(默认设置)调用这些输入函数之一,而且该套接字的接收缓冲区中没有数据可读,该进程将被投入睡眠,直到有一些数据到达。对于非阻塞的套接字,如果输入操作不能被满足(对于TCP套接字即至少有一个字节的数据可读,对于UDP套接字即有一个完整的数据报可读),相应调用将立即返回一个EWOULDBLOCK错误
    • 输出操作,包括write,writev,send,sendto和sendmsg共五个函数。对于一个TCP套接字,内核将从应用进程的缓冲区到该套接字的发送缓冲区复制数据。对于阻塞的套接字,如果其发送缓冲区中没有空间,进程将被投入睡眠,直到有空间为止。对于一个非阻塞的TCP套接字,如果其发送缓冲区中根本没有空间,输出函数调用将立即返回一个EWOULDBLOCK错误。如果其发送缓冲区中有一些空间,返回值将是内核能够复制到该缓冲区中的字节数。这个字节数也称为不足计数(short count)
    • 接受外来连接,即accept函数。如果对一个阻塞的套接字调用accept函数,并且尚无新的连接到达,调用进程将被投入睡眠。如果对一个非阻塞的套接字调用accept函数,并且尚无新的连接到达,accept调用将立即返回一个EWOULDBLOCK错误
    • 发起外出连接,即用于TCP的connect函数。(回顾一下,我们直到connect同样可用于UDP,不过它不能使一个“真正”的连接建立起来,它只是使内核保存对端的IP地址和端口号)。TCP连接的建立涉及一个三路握手过程,而且connect函数一直要等到客户收到对于自己的SYN的ACK为止才返回。这意味着TCP的每个connect总会阻塞其调用进程至少一个到服务器的RTT时间。如果对一个非阻塞的TCP套接字调用connect,并且连接不能立即建立,那么连接的建立能照样发起(例如送出TCP三路握手的第一个分组),不过会返回一个EINPROGRESS错误

ioctl操作

1.1

  • ioctl函数,传统上一直作为那些不适合归入其他精细定义类别的特性的系统接口。POSIX致力于摆脱处于标准化过程中的特定功能的ioctl接口,办法是为它们创造一些特殊的函数以取代ioctl请求

  • 网络程序(特别是服务器程序)经常在程序启动执行后使用ioctl获取所在主机全部网络接口的信息,包括:接口地址,是否支持广播,是否支持多播,等等。我们将自行开发用于返回这些信息的函数

1.2

  • ioctl函数,影响由fd参数引用的一个打开的文件
  • 声明:
    • #include <unistd.h>
    • int ioctl (int fd, int request, ... /* void *arg */);
  • 其中第三个参数总是一个指针,但指针的类型依赖于request参数
  • 我们可以把和网络相关的请求(request)划分为六类:
    • 套接字操作(是否位于带外标记等)
    • 文件操作(设置或清楚非阻塞标志等
    • 接口操作(返回接口列表,获取广播地址等)
    • ARP高速缓存操作(创建,修改,获取或删除)
    • 路由表操作(增加或删除)
    • 流系统

1.3

  • 需要处理网络接口的许多程序沿用的初始步骤之一,就是从内核获取配置在系统中的所有接口
  • 本任务由SIOCGIFCONF请求完成,它使用ifconfi接口,ifconfi又使用ifreq结构

路由套接字

1.1

  • 我们对路由套接字的主要兴趣点在于,使用sysctl函数检查路由表和接口列表。创建路由套接字(一个AF_ROUTE域的原始套接字)需要超级用户权限,然而使用sysctl检查路由表和接口列表的进程却不限用户权限
  • 声明:
    • #include <sys/param.h>
    • #include <sys/sysctl.h>
    • int sysctl (int *name, u_int namelen, void *oldp, size_t oldlenp, void *newp, size_t newlen);
  • name参数,是指定名字的一个整数数组,
  • namelen参数,指定该数组中的元素数目。
  • 为了获取某个值,oldp参数,指向一个供内核存放该值的缓冲区
  • oldlenp则是一个值-结果参数:函数被调用时,oldlenp指向的值指定该缓冲区的大小;函数返回时,该值给出内核存放在该缓冲区中的数据量
  • 为了设置某个新值,newp参数,指向一个大小为newlen参数值的缓冲区。如果不准备指定一个新值,那么newp应为一个空指针,newlen应为0

广播

1.1

  • 广播(broadcasting)和多播(multicasting)。本书迄今为止所有的例子处理的都是单播(unicasting):一个进程就与另一个进程通信。实际上TCP只支持单播寻址,而UDP和原始IP还支持其他寻址类型

  • IPv6往寻址体系结构中增加了任播(anycasting)方式。任播允许从一组通常提供相同服务的主机中选择一个(一般是选择按某种测度而言离源主机最近的)。

  • 多播支持在IPv4中是可选的,在IPv6中却是必需的

  • IPv6不支持广播。使用广播的任何IPv4应用程序一旦移植到IPv6就必须改用多播重新编写

  • 广播和多播要求用于UDP或原始IP,它们不能用于TCP

  • 广播的用途之一,是在本地子网定位一个服务器主机,前提是已知或认定这个服务器主机位于本地子网,但是不知道它的单播IP地址。这种操作也称为资源发现(resource discovery)。另一个用途是在有多个客户主机和单个服务器主机通信的局域网环境中尽量减少分组流通

  • 广播发送的数据报由发送主机某个所在子网上的所有主机接收。广播的劣势在于同一子网上的所有主机都必须处理数据报

多播

1.1

  • 单播地址标识单个IP接口,广播地址标识某个子网的所有IP接口,多播地址标识一组IP接口。

  • 单播和广播是寻址方案的两个极端(要么单个要么全部),多播则意在两者之间提供一种折衷方案

  • 多播数据报只应该由它感兴趣的接口接收,也就是说由运行相应多播会话应用系统的主机上的接口接收

  • 套接字API为支持多播而增添的内容比较简单:九个套接字选项

    • 其中三个影响目的地址为多播地址的UDP数据报的发送
    • 另外六个影响主机对于多播数据报的接收

信号驱动式I/O

1.1

  • 信号驱动式I/O,是指进程预先告知内核,使得当某个描述符上发生某事时,内核使用信号通知相关进程。

  • POSIX通过aio_XXX函数提供真正的异步I/O。这些函数允许进程指定I/O操作完成时是否由内核产生信号以及产生什么信号。

  • 针对一个套接字使用信号驱动式I/O(SIGIO)要求进程执行以下三个步骤:

    • 建立SIGIO信号的信号处理函数
    • 设置该套接字的属主,通常使用fcntl的F_SETOWN命令设置
    • 开启该套接字的信号驱动式I/O,通常通过使用fcntl的F_SETFL命令打开O_ASYNC标志完成

1.2

  • 在UDP上使用信号驱动式I/O是简单的。SIGIO信号在发生以下事件时产生:

    • 数据报到达套接字;
    • 套接字上发生异步错误
  • 因此,当捕获对于某个UDP套接字的SIGIO信号时,我们调用recvfrom或者读入到达的数据报,或者获取发生的异步错误。

  • 信号驱动式I/O对于TCP套接字近乎无用。问题在于该信号产生的过于频繁,并且它的出现并没有告诉我们发生了什么事件

线程

1.1

  • 在传统的UNIX模型中,当一个进程需要另一个实体来完成某事时,它就fork一个子进程并让子进程去执行处理。

  • Unix上的大多数网络服务器程序就是这么编写的:父进程accept一个连接,fork一个子进程,该子进程处理与该连接对端的客户之间的通信。

  • 尽管这种范式多少年来一直用的挺好,fork调用却存在一些问题:

    • fork是昂贵的。fork要把父进程的内存映像复制到子进程,并在子进程中复制所有描述符,如此等等。当今的实现使用称为写时复制(copy-on-write)的技术,用以避免在子进程切实需要自己的副本之前把父进程的数据空间复制到子进程。然而即便有这样的优化措施,fork仍然是昂贵的
    • fork返回之后父子进程之间信息的传递需要进程间通信(IPC)机制。调用fork之前父进程向尚未存在的子进程传递信息相当容易,因此子进程将从父进程数据空间及所有描述符的一个副本开始运行。然而从子进程往父进程返回信息却比较费力。
  • 线程,有助于解决这两个问题。线程有时称为轻权进程(lightweight process),因为线程比进程“权重轻些”。也就是说,线程的创建可能比进程的创建快10-100倍

  • 同一进程内的所有线程共享相同的全局内存。这使得线程之间易于共享信息,然而伴随这种简易性而来的确实同步(synchronization)问题

  • 同一进程内的所有线程除了共享全局变量外,还共享:

    • 进程指令;
    • 大多数数据
    • 打开的文件(即描述符)
    • 信号处理函数和信号处置
    • 当前工作目录
    • 用户ID和组ID
  • 不过每个线程有各自的:

    • 线程ID;
    • 寄存器集合,包括程序计数器和栈指针
    • 栈(用于存放局部变量和返回地址);
    • errno;
    • 信号掩码;
    • 优先级

1.2

  • 当一个程序由exec启动执行时,称为初始线程(initial thread)或主线程(main thread)的单个线程就创建了。其余线程则由pthread_create函数创建
  • 声明:
    • #include <pthread.h>
    • int pthread_create (pthread_t *tid, const pthread_attr_t *attr, void *(*func)(void *), void *arg);
  • 一个进程内的每个线程都由一个线程ID(thread ID)标识,其数据类型为pthread_t(往往是unsigned int)。如果新的线程成功创建,其ID就通过tid指针返回
  • 每个线程都有许多属性(attribute):优先级,初始栈大小,是否应该成为一个守护线程,等等。
  • 创建一个线程时,我们最后指定的参数是由该线程执行的函数及其参数。该线程通过调用这个函数开始执行,然后或显示终止(通过调用pthread_exit),或者隐式地终止(通过让函数返回)。该函数的地址由func参数指定,该函数的唯一调用参数是指针arg。如果我们需要给该函数传递多个参数,我们就得把它们打包成一个结构,然后把这个结构的地址作为单个参数传递给这个起始函数。
  • 注意func和arg的声明。func所指函数作为参数接受一个通用指针(void *),又作为返回值返回一个通用指针(void *)。这使得我们可以把一个指针(它指向我们期望的任何内容)传递给线程,又允许线程返回一个指针(它同样指向我们期望的任何内容)
  • 通常情况下Pthread函数的返回值成功时为0,出错时为某个非0值

1.3

  • 我们可以通过调用pthread_join等待一个给定线程终止。对比线程和UNIX进程,pthread_create类似于fork,pthread_join类似于waitpid
  • 声明:
    • #include <pthread.h>
    • int pthread_join (pthread_t *tid, void **status);
  • 我们必须指定要等待线程的tid。不幸的是,Pthread没有办法等待任意一个线程(类似指定进程ID参数为-1调用waitpid)
  • 如果status指针非空,来自所等待线程的返回值(一个指向某个对象的指针)将存入由status指向的位置

1.4

  • 每个线程都有一个在所属进程内标识自身的ID。线程ID由pthread_create返回,而且我们已经看到pthread_join使用它。每个线程使用pthread_self获取自身的线程ID
  • 声明:
    • #include <pthread.h>
    • pthread_t pthread_self (void);
  • 对比线程和UNIX进程,pthread_self类似于getpid

1.5

  • 一个线程或者是可汇合的(joinable,默认值),或者是脱离的(detached)。当一个可汇合的线程终止时,它的线程ID和退出状态将留存到另一个线程对他调用pthread_join。脱离的线程却像守护进程,当它们终止时,所有相关资源都被释放,我们不能等待它们终止。如果一个线程需要知道另一个线程什么时候终止,那就最好保持第二个线程的可汇合状态。
  • pthread_detach函数,把指定的线程转变为脱离状态
  • 声明:
    • #include <pthread.h>
    • int pthread_detach (pthread_t tid);
  • 本函数通常由想让自己脱离的线程调用,就如以下语句:
    • pthread_detach (pthread_self());

1.6

  • 让一个线程终止的方法之一,是调用pthread_exit。

  • 声明:

    • #include <pthread.h>
    • void pthread_exit (void *status);
  • 指针status不能指向局部于调用线程的对象,因为线程终止时这样的对象也消失

  • 让一个线程终止的另外两个方法是:

    • 启动线程的函数(即pthread_create的第三个参数)可以返回。既然该函数必须声明成返回一个void指针,它的返回值就是相应线程的终止状态
    • 如果进程的main函数返回或者任何线程调用了exit,整个进程就终止,其中包括它的任何线程。

1.7

  • 我们称线程编程为并发编程(concurrent programming)或并行编程(parallel programming),因为多个线程可以并发地(或并行地)运行且访问相同的变量

  • 我们刚才讨论的多个线程更改一个共享变量的问题是最简单的问题。其解决办法是,使用一个互斥锁(mutex,代表mutual exclusion)保护这个共享变量;访问该变量的前提条件是持有该互斥锁。

  • 按照Pthread,互斥锁是类型为pthread_mutex_t的变量。我们使用以下两个函数为一个互斥锁上锁和解锁:

  • 声明:

    • #include <pthread.h>
    • int pthread_mutex_lock (pthread_mutex_t *mptr);
    • int pthread_mutex_unlock (pthread_mutex_t *mptr);
  • 如果试图上锁已被另外某个线程锁住的一个互斥锁,本线程将被阻塞,直到该互斥锁倍解锁为止

1.8

  • 互斥锁适合于防止同时访问某个共享变量,但是我们需要另外某种在等待某个条件发生期间能让我们进入睡眠的东西

  • 轮询(polling),就是不断地循环,每次循环检查一下条件,相当浪费CPU事件

  • 我们需要一个让主循环进入睡眠,直到某个线程通知它有事可做才醒来的方法。条件变量(condition variable)结合互斥锁能够提供这个功能。互斥锁提供互斥机制,条件变量提供信号机制

  • 按照Pthread,条件变量是类型为pthread_cond_t的变量。以下两个函数使用条件变量:

  • 声明:

    • #include <pthread.h>
    • int pthread_cond_wait (pthread_cond_t *cptr, pthread_mutex_t *mptr);
    • int pthread_cond_signal (pthread_cond_t *cptr);
  • 第二个函数的名字中”signal“一词并不指称Unix的SIGXXX信号

  • 为什么每个条件变量都要关联一个互斥锁呢?因为”条件“通常是线程之间共享的某个变量的值。允许不同线程设置和测试该变量要求有一个与该变量关联的互斥锁

  • 要求pthread_cond_wait被调用时其所关联的互斥锁必须是上锁的,该函数作为单个原子操作解锁该互斥锁并把调用线程投入睡眠也是出于这个理由

  • pthread_cond_signal通常唤醒等在相应条件变量上的单个线程。有时候一个线程直到自己应该唤醒多个线程,这种情况下它可以调用pthread_cond_broadcast唤醒等在相应条件变量上的所有线程

  • 声明:

    • #include <pthread.h>
    • int pthread_cond_broadcast (pthread_cond_t *cptr);
    • int pthread_cond_timedwait (pthread_cond_t *cptr, pthread_mutex_t *mptr, const struct timespec *abstime);
  • pthread_cond_timedwait允许线程设置一个阻塞时间的限制。

1.9

  • 创建一个新线程通常比使用fork派生一个新进程快得多。仅仅这一点就能够体现线程在繁重使用的网络服务器上的优势,然而线程编程是一个新的编程范式,需要有所训练。
  • 同一进程内的所有线程共享全局变量和描述符,从而允许不同线程之间共享这些信息。然而这种共享却引入了同步问题,我们必须使用的Pthread同步原语是互斥锁和条件变量。共享数据的同步几乎是每个线程化应用程序必不可少的部分。
  • 我们将在第三十章中重新回到线程模型,讨论另外一个服务器程序设计范式:服务器在启动时创建一个线程池,下一个客户请求就由该池中某个闲置的线程来处理

IP选项

1.1

  • 源路径(source route),是由IP数据报的发送者指定的一个IP地址列表。如果源路径是严格的(strict),那么数据报必须且只能逐一经过所列的节点,也就是说列在源路径中的所有节点必须前后互为邻居。如果源路径是宽松的(loose),那么数据报必须逐一经过所列的节点,不过也可以经过未列在源路径中的其他节点

  • 在10个已定义的IPv4选项中最常用的是源路径选项,不过出于安全考虑,它的使用正在日益萎缩。IPv4首部中选项的访问通过IP_OPTIONS套接字选项完成

原始套接字

1.1

  • 原始套接字提供普通的TCP和UDP套接字所不提供的以下三个能力:

    • 有了原始套接字,进程可以读与写ICMPv4, IGMPv4和ICMPv6等分组
    • 有了原始套接字,进程可以读写内核不处理其协议字段的IPv4数据报
    • 有了原始套接字,进程还可以使用IP_HDRINCL套接字选项自行构造IPv4首部,通常用于诊断目的
  • ping和traceroute这两个常用的诊断工具,使用原始套接字完成任务

数据链路访问

1.1

  • 目前大多数操作系统都为应用提供访问数据链路层的强大功能。这种功能可以提供如下能力:
    • 能够监视由数据链路层接收的分组,使得诸如tcpdump之类的程序能够在普通计算机系统上运行,而无需使用专门的硬件设备来监视分组。
    • 能够作为普通应用进程而不是内核的一部分运行某些程序

1.2

  • Linux先后有两个从数据链路层接收分组的方法。

    • 较旧的方法,是创建类型为SOCK_PACKET的套接字,这个方法的可用面较宽,不过缺乏灵活性
    • 较新的方法,是创建协议族为PF_PACKET的套接字,这个方法引入了更多的过滤和性能特性
  • 从数据链路层接收所有帧应如下创建套接字:

    • fd = socket (PF_PACKET, SOCK_RAW, htons(ETH_P_ALL)); /* 较新方法 */
    • fd = socket (AF_INET, SOCK_PACKET, htons(ETH_P_ALL)); /* 较旧方法 */
  • 如果只想捕获IPv4帧,那就如下创建套接字

    • fd = socket (PF_PACKET, SOCK_RAW, htons(ETH_P_IP)); /* 较新方法 */
    • fd = socket (AF_INET, SOCK_PACKET, htons(ETH_P_IP)); /* 较旧方法 */
  • 用作socket调用的第三个参数的常值还有ETH_P_ARP, ETH_P_IPV6等

1.3

  • libpcap,是访问操作系统所提供的分组捕获机制的分组捕获函数库,它是与实现无关的。
  • 目前它只支持分组的读入

1.4

  • libnet函数库,提供构造任意协议的分组并将其输出到网络中的接口。它以与实现无关的方式提供原始套接字访问方式和数据链路访问的方式
  • libnet隐藏了构造IP,UDP和TCP首部的许多细节,并提供简单且便于移植的数据链路和原始套接字写出访问接口

1.5

  • 原始套接字,使得我们有能力读写内核不理解的IP数据报;数据链路层访问,则把这个能力进一步扩展成与读写任何类型的数据链路帧,而不仅仅是IP数据报。tcpdump也许是直接访问数据链路层的最常用程序
  • 不同操作系统有不同的数据链路层访问方法。但是,如果使用公开可得的分组捕获函数库libpcap,我们就可以忽略所有这些区别,依然编写出可移植的代码
  • 在不同系统上编写原始数据报可能各不相同。公开可得的libnet函数库隐藏了这些差异,所提供的数据接口即可以通过原始套接字访问,也可以在数据链路层上直接访问

客户/服务器程序设计范式

1.1

  • 当开发一个Unix服务器程序时,我们有如下类型的进程控制可供选择

    • 本书第一个服务器程序即图1-9是一个迭代服务器(iterative server)程序,不过这种类型的使用情形极为有限,因为这样的服务器在完成对当前客户的服务之前无法处理已经等待服务的新客户
    • 图5-2是本书第一个并发服务器(concurrent server)程序,它为每个客户调用fork派生一个子进程。传统上大多数Unix服务器程序属于这种类型
    • 在6.8节,我们开发的另一个版本的TCP服务器程序由使用select处理任意多个客户的单个进程构成
    • 在图26-3中,我们的并发服务器程序被改为服务器为每个客户创建一个线程,以取代派生一个进程
  • 我们将在本章探究并发服务器程序设计的另两类变体:

    • 预先派生子进程(preforking),是让服务器在启动阶段调用fork创建一个子进程池。每个客户请求由当前可用子进程池中的某个(闲置)子进程处理
    • 预先创建线程(prethreading),是让服务器在启动阶段创建一个线程池,每个客户由当前可用线程池中的某个(闲置)线程处理
  • 我们将在本章审视预先派生子进程和预先创建线程这两种类型的众多细节:

    • 如果池中进程和线程不够多怎么办?
    • 如果池中进程和线程过多怎么办?
    • 父进程和子进程之间以及多个线程之间怎样彼此同步?
  • 我们已经探究了客户程序的各种设计范式,这里有必要汇总它们各自的优缺点:

    • 图5-5是基本的TCP客户程序。该程序存在两个问题。首先,进程在被阻塞以等待用户输入期间,看不到诸如对端关闭连接等网络事件。其次,它以停-等模式运作,批处理效率极低
    • 图6-9是下一个迭代客户程序,它通过调用select使得进程能够在等待用户输入期间得到网络事件通知。然而该程序存在不能正确地批量输入的问题。图6-13通过使用shutdown函数解决了这个问题
    • 从图16-3开始给出的是使用非阻塞式I/O实现的客户程序
    • 第一个超越单进程单线程设计范畴的客户程序是图16-10,它使用fork派生一个子进程,并由父进程(或子进程)处理从客户到服务器的数据,由子进程(或父进程)处理从服务器到客户的数据
    • 图26-2使用两个线程取代两个进程
  • 非阻塞式I/O版本尽管是最快的,其代码却比较复杂;使用两个进程或两个线程的版本相比之下代码简化得多,而运行速度只是稍逊而已

1.2

  • 传统上并发服务器调用fork派生一个子进程来处理每个客户。这使得服务器能够同时为多个客户服务,每个进程一个客户。
  • 客户数目的唯一限制是操作系统对以其名义运行服务器的用户ID能够同时拥有多少子进程的限制

1.3

  • 我们的第一个“增强”型服务器程序使用称为预先派生子进程(preforking)的技术。

  • 使用该技术的服务器不像传统意义的并发服务器那样为每个客户现场派生一个子进程,而死在启动阶段预先派生一定数量的子进程,当各个客户连接到达时,这些子进程立即就能为它们服务

  • 这种技术的优点在于无须引入父进程执行fork的开销就能处理新到的客户。缺点则是父进程必须在服务器启动阶段猜测需要预先派生多少子进程

  • 通过增加一些代码,服务器总能应对客户负载的变动。父进程必须做的就是持续监视可用(即闲置)子进程数,一旦该值降到低于某个阈值就派生额外的子进程。同样,一旦该值超过另一个阈值就终止一些过剩的子进程

1.4

  • BSD实现允许多个进程在引用同一个监听套接字的描述符上调用accept,然而这种做法也仅仅适用于在内核中实现accept的源自Berkeley的内核。相反,作为一个库函数实现accept的System V内核不允许这么做。
  • 解决办法是,让应用进程在调用accept前后安置某种形式的锁(lock),这样任意时刻只有一个子进程阻塞在accept调用中,其他子进程则则色在试图获取用于保护accept的锁上
  • 正如本系列丛书第二卷所述,我们有多种方法可用于提供包绕accept调用的上锁功能。本节我们使用以fcntl函数呈现的POSIX文件上锁功能

1.5

  • 对预先派生子进程服务器程序的最后一个修改版本,是只让父进程调用accept,然后把所接受的已连接套接字“传递”给某个子进程。这么做绕过了为所有子进程的accept调用提供上锁保护的可能需求,不过需要从父进程到子进程的某种形式的描述符传递。这种技术会使代码多少有点复杂,因为父进程必须跟踪子进程的忙闲状态,以便给空闲子进程传递新的套接字

  • 我们首先查看图30-21给出的child_make函数。在调用fork之前先创建一个字节流管道,它是一对Unix域字节流套接字(第十五章)。派生出子进程之后,父进程关闭其中一个描述符(sockfd[1]),子进程关闭另一个描述符(sockfd[0])。子进程还把流管道的自身拥有端(sockfd[1])复制到标准错误输出,这样每个子进程就通过读写标准错误输出和父进程通信。

1.6

  • 在支持线程的系统上,我们有理由预期在服务器启动阶段预先创建一个线程池以取代为每个客户现场创建一个线程的做法有类似的性能加速。
  • 本服务器的基本设计是,预先创建一个线程池,并让每个线程各自调用accept。取代让每个线程都阻塞在accept调用之中的做法,我们改用互斥锁以保证任何时刻只有一个线程在调用accept。这里没有理由使用文件上锁保护各个线程中的accept调用,因为对于单个进程中的多个线程,我们总可以使用互斥锁达到同样的目的

1.7

  • 最后一个使用线程的服务器程序设计范式,是在程序启动阶段创建一个线程池之后只让主线程调用accept并把每个客户连接传递给池中某个可用线程
  • 本设计范式的问题,在于主线程如何把一个已连接套接字传递给线程池中某个可用线程。这里有多个实现手段。
    • 我们原本可以如前使用描述符传递,不过既然所有线程和所有描述符都在同一个进程之内,我们没有必要把一个描述符从一个线程传递到另一个线程
    • 接收线程只需要知道这个已连接套接字描述符的值,二描述符传递实际传递的并非这个值,而是对这个套接字的一个引用,因而将返回一个不同于原值的描述符(该套接字的引用计数也被递增)

1.8

  • 我们在本章中讨论了九个不同的服务器程序设计范式,并针对同一个Web风格的客户程序分别运行了它们,以比较它们花在执行进程控制上的CPU时间

    • 迭代服务器(无进程控制,用作测量基准)
    • 并发服务器,每个客户请求fork一个子进程
    • 预先派生子进程,每个子进程无保护地调用accept
    • 预先派生子进程,使用文件上锁保护accept
    • 预先派生子进程,使用线程互斥锁上锁保护accept
    • 预先派生子进程,父进程向子进程传递套接字描述符
    • 并发服务器,每个客户请求创建一个线程
    • 预先创建线程服务器,使用互斥锁上锁保护accept
    • 预先创建线程服务器,由主线程调用accept
  • 经过比较,我们可以得出以下几点总结性意见:

    • 当系统负载较轻时,每来一个客户请求现场派生一个子进程为之服务的传统并发服务器程序模型就足够了。
    • 相比传统的每个客户fork一次设计范式,预先创建一个子进程池或一个线程池的设计范式能够把进程控制CPU时间降低10或以上。编写这些范式的程序并不复杂,不过需超越本章所给的例子的是:监视闲置子进程个数,随着所服务客户数的动态变化而增加或减少这个数目
    • 某些实现允许多个子进程或线程阻塞在同一个accept调用中,另一些实现却要求包绕accept调用安置某种类型的锁加以保护。文件上锁或Pthread互斥锁上锁都可以使用
    • 让所有子进程或线程自行调用accept通常比让父进程或主线程独自调用accept并把描述符传递给子进程或线程来的简单而快速
    • 由于潜在select冲突的原因,让所有子进程或线程阻塞在同一个accept调用中比让它们阻塞在同一个select调用中更可取
    • 使用线程通常远快于使用进程

1.1

  • 我们将在本章给出流系统的概貌以及应用程序用于访问某个流的函数

附录A

1.1

  • IP层提供无连接不可靠的数据报递送服务。它会尽最大努力把IP数据报递送到指定的目的地,然而并不保证它们一定到达,也不保证它们的到达顺序与发送顺序一致,还不保证每个IP数据报只到达一次。

  • 任何期望的可靠性(即无差错按顺序不重复地递送用户顺序)必须由上层提供。

    • 对于TCP(或SCTP)应用程序而言,这由TCP(或SCTP)本身完成
    • 对于UDP应用程序而言,这得由应用程序完成,因为UDP是不可靠的
  • IP层最重要的功能之一是路由(routing)。每个IP数据报包含一个源地址和一个目的地址

1.2

  • 32位长度的IPv4地址通常书写成以点号分隔的4个十进制数,称为点分十进制数记法(dotted-decimal notation),其中每个十进制数代表32位地址4个字节中的某一个
  • 无论在何时谈到IPv4网络或子网地址,所说的都是一个32位网络地址和一个相应的32位掩码。掩码中值为1的位涵盖网络地址部分,值为0的位涵盖主机地址部分。既然掩码中值为1的位置总是从最左位向右连续排列,值为0的位总是从最右位向左连续排列,因此地址掩码也可以使用表示从最左位向右排列的值为1的连续位数的前缀长度(prefix length)指定。
  • 举例来说,掩码是255.255.255.0,则前缀长度为24。这些IPv4地址被认为无类的,之所以这么称呼,是因为现在掩码是显式指定而非由地址类型暗指的。
  • IPv4网络地址通常书写成一个点分十进制数串,后跟一个斜杠,再跟以前缀长度。192.169.4.16/24
  • 使用无类地址要求无类路由,它通常称为无类域间路由(classless interdomain routing, CIDR)。使用CIDR的目的,在于减少因特网主干路由表的大小,延缓IPv4地址耗尽的速率。

1.3

  • IPv4地址通常划分子网。这么做增加了另外一级地址层次:

    • 网络ID(分配给网点)
    • 子网ID(由网点选择)
    • 主机ID(由网点选择)
  • 网络ID和子网ID之间的界线,由所分配网络地址的前缀长度确定,而这个前缀长度通常由相应的组织机构的ISP赋予。然而子网ID和主机ID之间的界线却由网点选择。

  • 某个给定子网上所有主机都共享同一个子网掩码(subnet mask),它指定子网ID和主机ID之间的界线。子网掩码中值为1的位涵盖网络ID和子网ID,值为0的位则涵盖主机ID

1.4

  • 环回地址,按照约定,地址127.0.0.1赋予环回接口。任何发送到这个IP地址的分组在内部被环送回来作为IP模块的输入,因而这些分组根本不会出现在网络上
  • 我们在同一个主机上测试客户和服务器程序时经常使用改地址。该地址通常为人所知的名字是INADDR_LOOPBACK

附录C 调试技术

1.1

1.2

  • netstat程序,该程序服务于多个目的:
    • 展示网络端点的状态
    • 展示某个主机上各个接口所属的多播组。-ia标志是展露多播组的通常方式
    • 使用-s选项显示各个协议的统计信息
    • 使用-r选项显示路由表或使用-i选项显示接口信息

1.3

  • lsof程序,名字lsof代表“列出打开的文件(list open files”。与tcpdump一样,losf也是一个公开可得的方便调试的工具
  • losf的常见用途之一是,找出哪个进程在指定的IP地址或端口上打开了一个套接字。netstat告诉我们那些IP地址和端口正在使用中以及各个TCP连接的状态,却没有标识相应的进程。losf弥补了这个缺陷
  • 该程序的常见用途之一是:如果在启动一个捆绑其众所周知端口的服务器时得到改地址已在使用的出错消息,那么我们可以使用losf找出正在使用该端口的进程

简介

1.1 概述

  • IPC,是进程间通信(interprocess communication)的简称。传统上该术语描述的是运行在某个操作系统之上的不同进程间各种消息传递(message passing)的方式

  • 在Unix操作系统过去三十年的演变史中,消息传递经历了如下几个发展阶段:

    • 管道(pipe,第四章),是第一个广泛使用的IPC形式,既可在程序中使用,也可从shell中使用。管道的问题在于它们只能在具有共同祖先(指父子进程关系)的进程间使用,不过该问题已随有名管道(named pipe)既FIFO(第四章)的引入而解决了
    • System V消息队列(System V message queue,第六章),是在20世纪80年代早期加到System V内核中的。它们可用在同一主机上有亲缘关系或无亲缘关系的进程之间。(谈论Unix进程时,有亲缘关系(related)的说法意味着所论及的进程具有某个共同的祖先。说的更明白点,这些有亲缘关系的进程是从该祖先进程经过一次或多次fork派生来的。我们还得注意,从理论上来说,所有Unix进程与init进程都有亲缘关系,它是系统自举时启动所有初始化进程的祖先进程。然而从实践上说,进程亲缘关系开始于一个登录shell(称为一个会话)以及由该shell派生的所有进程
    • Posix消息队列(Posix消息队列,第五章),是由Posix实时标准加入的,它们可用在同一主机上有亲缘关系和无亲缘关系的进程之间
    • 远程过程调用(Remote Procedure Call,简称RPC,第五部分),出现在20世纪80年代中期,它是从一个系统(客户主机)上某个程序调用另一个系统(服务器主机)上某个函数的一种方法,是作为显式网络编程的一种替换方法开发的。既然客户和服务器之间通常传递一些信息(被调用函数的参数与返回值),而且RPC可用在同一主机上的客户和服务器之间,因此可认为RPC是另一种形式的消息传递
  • 看一看由Unix提供的各种同步形式的演变同样颇有效益

    • 需要某种同步形式(往往是为了防止多个进程同时修改同一文件)的早期程序使用了文件系统的诡秘特性
    • 记录上锁(record locking,第九章),是在20世纪80年代早期加到Unix内核中的,然后在1988年由Posix.1标准化的
    • System V信号量(System V semaphore,第十一章),是在System V消息队列加入System V内核的同时(20世纪80年代早期)伴随System V共享内存区(System V shared memory)加入的。当今多数版本的Unix都支持它们
    • Posix信号量(Posix semaphore,第十章)和Posix共享内存区(Posix shared memory,第十三章)也由Posix实时标准加入
    • 互斥锁(mutex)和条件变量(condition variable,第七章),是由Posix线程标准定义的两种同步形式。尽管往往用于线程间的同步,它们也能提供不同进程间的同步
    • 读写锁(read-write lock,第八章),是另一种形式的同步。它们还没有被Posix标准化,不过也许不久后会被标准化

1.2 进程,线程与信息共享

  • 按照传统的Unix编程模型,我们在一个系统上运行多个进程,每个进程都有各自的地址空间

  • Unix进程间的信息共享可以有多种方式,总结:

    • 两个进程共享存留于文件系统中某个文件上的某些信息。为访问这些信息,每个进程都得穿越内核(例如read,write,lseek等)。当一个文件有待更新时,某种形式的同步是必要的,这样既可保护多个写入者,防止互相串扰,也可保护一个或多个读出者,防止写入者的干扰
    • 两个进程共享驻留于内核中的某些信息。管道是这种共享类型的一个例子,System V消息队列和System V信号量也是。现在访问共享信息的每次操作涉及对内核的一次系统调用
    • 两个进程有一个双方都能访问的共享内存区。每个进程一旦设置好该共享内存区,就能根本不涉及内核而访问其中的数据。共享该内存区的进程需要某种形式的同步
  • 需要注意的是,没有任何东西限制任何IPC技术只能使用两个进程。我们讲述的技术适用于任意数目的进程

  • 虽然Unix系统中进程的概念已使用了很久,一个给定进程内多个线程(thread)的概念却相对较新。Posix.1线程标准(称为Pthreads)是于1995年通过的。

  • 从IPC角度看来,一个给定进程内的所有线程共享同样的全局变量(也就是说共享内存区的概念对这种模型来说是内存在)。然而我们必须关注的是各个线程间对全局数据的同步访问。同步尽管不是一种明确的IPC形式,但它确实伴随许多形式的IPC使用,以控制对某些共享数据的访问

  • 本书中,我们讲述进程间的IPC和线程间的IPC。我们假设有一个线程环境,并作类似如下形式的陈述:如果管道为空,调用线程就阻塞在它的read调用上,直到某个线程往该管道写入数据。要是你的系统不支持线程,那你可以将该句子中的”线程“替换成”进程“,从而提供“阻塞在对空管道的read调用上”的经典Unix定义。

  • 然而在支持线程的系统上,只有对空管道调用read的那个线程阻塞,同一进程中的其余线程才可以继续执行。向该空管道写入数据的工作既可以由同一进程中的另一个线程去做,也可以由另一个进程中的某个线程去做

1.3 IPC对象的持续性

  • 我们可以把任意类型的IPC的持续性(persistence)定义成该类型的一个对象一直存在多长时间。以下有三种类型的持续性:
    • 随进程持续的(process-persistent)IPC对象,一直存在到打开着该对象的最后一个进程关闭该对象为止。例如管道和FIFO就是这种对象。
    • 随内核持续的(kernel-persistent)IPC对象,一直存在到内核重新自举或显式删除该对象为止。例如System V的消息队列,信号量和共享内存区就是此类对象。Posix的消息队列,信号量和共享内存区必须至少是随内核持续的,但也可以是随文件系统持续的,具体取决于实现
    • 随文件系统持续的(filesystem-persistent)IPC对象,一直存在到显式删除该对象为止。即使内核重新自举了,该对象还是保持其值。Posix消息队列,信号量和共享内存区如果是使用映射文件实现的(不是必需条件),那么它们就是随文件系统持续的

1.4 名字空间

  • 当两个或多个无亲缘关系的进程使用某种类型的IPC对象来彼此交换信息时,该IPC对象必须有一个某种形式的名字(name)或标识符(identifier),这样其中一个进程(往往是服务器)可以创建该IPC对象,其余进程则可以指定同一个IPC对象
  • 管道没有名字(因此不能用于无亲缘关系的进程间),但是FIFO有一个在文件系统中的Unix路径名作为其标识符(因此可用于无亲缘关系的进程间)。
  • 对于一种给定的IPC类型,其可能的名字的集合称为它的名字空间(name space)。名字空间非常重要,因为对于除普通管道以外的所有形式的IPC来说,名字是客户与服务器彼此连接以交换消息的手段

1.4

  • 为分析各种特性,全书主要使用了三种交互模式:
    • 文件服务器:客户-服务器应用程序,客户向服务器发送一个路径名,服务器把该文件的内容返回给客户
    • 生产者-消费者:一个或多个线程或进程(生产者)把数据放到一个共享缓冲区种,另有一个或多个线程或进程(消费者)对该共享缓冲区种的数据进行操作
    • 序列号持续增1:一个或多个线程或进程给一个共享的序列号持续增1.该序列号有时在一个共享文件中,有时在共享内存区种
  • 第一个例子分析各种形式的消息传递,另外两个例子则分析各种类型的同步和共享内存区

1.5 小结

  • IPC传统上是Unix中一个杂乱不堪的领域,虽然有了各种各样的解决办法,但没有一个是完美的。我们的讨论分成四个主要领域:
    • 消息传递(管道,FIFO,消息队列)
    • 同步(互斥锁,条件变量,读写锁,信号量)
    • 共享内存区(匿名共享内存区,有名共享内存区)
    • 过程调用(Solaris门,Sun RPC)

Posix IPC

1.1 概述

  • 以下三种类型的IPC合称为 Posix IPC
    • Posix消息队列(第五章)
    • Posix信号量(第十章)
    • Posix共享内存区(第十三章)

1.2 小结

  • 三种类型的Posix IPC:消息队列,信号量,共享内存区,都是用路径名标识的。但是这些路径名既可以是文件系统中的实际路径名,也可以不是,而这一点不一致性会导致一个移植性问题。全书采用的解决办法是使用我们自己的px_ipc_name函数
  • 当创建或打开一个IPC对象时,我们指定一组类似于open函数所用的标志。创建一个新的IPC对象时,我们必须给这个新对象指定访问呢权限,所用的是同样由open函数使用的S_xxx常值

System V IPC

1.1 概述

  • 以下三种类型的IPC合称为System V IPC
    • System V消息队列(第六章)
    • System V信号量(第十一章)
    • System V共享内存区(第十四章)
  • 这个称谓作为这三种IPC机制的通常是因为它们源自System V Unix

管道和FIFO

1.1 概述

  • 管道是最初的Unix IPC形式,可追溯到1973的Unix第三版。
  • 尽管对于许多操作来说很有用,但它们的根据局限在于没有名字,从而只能由有亲缘关系的进程使用。这一点随FIFO的加入在System III Unix(1982年)中得以改正。FIFO有时称为有名管道(named pipe)。管道和FIFO都是使用通常的read和write函数访问的
  • 技术上讲,自从可以在进程间传递描述符后,管道也能用于无亲缘关系的进程间。然而现实中,管道通常用于具有共同祖先的进程间

1.2 管道

  • 所有式样的Unix都提供管道。它由pipe函数创建,提供一个单路(单向)数据流

  • 声明:

    • #include <unistd.h>
    • int pipe(int fd[2]);
  • 该函数返回两个文件描述符:fd[0]和fd[1]。前者打开来读,后者打开来写

  • 有些版本的Unix提供全双工管道,也就是说这些管道的两端都是即可用于读,也可用于写。创建一个全双工IPC管道的另一种方法是使用UNPV1的14.3节中讲述的socketpair函数,它在大多数现行Unix系统上都能工作。然而管道的最常见用途是用在各种shell中,这种情况下半双工管道足够了

  • 尽管管道是由单个进程创建的,它却很少在单个进程内使用。管道的典型用途是以下述方式为两个不同进程(一个是父进程,一个是子进程)提供进程间的通信手段。

    • 首先,由一个进程(它将成为父进程)创建一个管道后调用fork派生一个自身的副本
    • 接着,父进程关闭这个管道的读出端,子进程关闭同一管道的写入端。这就在父子进程间提供了一个单向数据流
  • 我们在某个Unix shell中输入一个像如下这样的命令时:who | sort|lp,该shell将执行上述步骤创建三个进程和其间的两个管道。它还把每个管道的读出端复制到相应进程的标准输入,把每个管道的写入端复制到相应进程的标准输出

  • 到此为止所示的所有管道都是半双工的即单向的,只提供一个方向的数据流。当需要一个双向数据流时,我们必须创建两个管道,每个方向一个。实际步骤如下:

    • 创建管道1(fd1[0]和fd1[1])和管道2(fd2[0]和fd2[1])
    • fork
    • 父进程关闭管道1的读出端(fd1[0])
    • 父进程关闭管道2的写入端(fd2[1])
    • 父进程关闭管道1的写入端(fd1[1])
    • 父进程关闭管道2的读出端(fd2[0])

1.3 FIFO

  • FIFO,指代先进先出(first in, first out),Unix中的FIFO类似于管道。它是一个单项(半双工)数据流。不同于管道的是,每个FIFO有一个路径名与之关联,从而允许无亲缘关系的进程访问同一个FIFO。FIFO也称为有名管道(named pipe)
  • FIFO由mkfifo函数创建
  • 声明:
    • #include <sys/types.h>
    • #include <sys/stat.h>
    • int mkfifo(const char *pathname, mode_t mode);
  • 其中pathname,是一个普通的Unix路径名,它是该FIFO的名字
  • mode参数,指定文件权限位,类似于open的第二个参数
  • mkfifo函数,已隐含指定O_CREAT | O_EXCL。也就是说,它要么创建一个新的FIFO,要么返回一个EEXIST错误(如果所指定名字的FIFO已经存在)
  • 在创建出一个FIFO后,它必须或者打开来读,或者打开来写。所用的可以是open函数,也可以是某个标准I/O打开函数
  • 对管道或FIFO的write总是往末尾添加数据,对他们的read则总是从开头返回数据。如果对管道或FIFO调用lseek,那就返回ESPIPE错误

1.4 管道和FIFO的额外属性

  • 我们需要就管道和FIFO的打开,读出和写入更为详细地描述它们的某些属性。

  • 首先,一个描述符能以两种方式设置成非阻塞

    • 调用open时可指定O_NONBLOCK标志
    • 如果一个描述符已经打开,那么可以调用fcntl以启用O_NONBLOCK标志。对于管道来说,必须使用这种技术,因为管道没有open调用,在pipe调用中也无法指定O_NONBLOCK标志。使用fcntl时,我们先使用F_GETFL命令获取当前文件状态标志,将它与O_NONBLOCK标志按位或后,再使用F_SETFL命令存储这些文件状态标志
  • 关于管道或FIFO的读出与写入的若干额外规则:

    • 如果请求读出的数据量多余管道或FIFO中当前可用数据量,那么只返回这些可用的数据。我们必须准备好处理来自read的小于所请求数目的返回值
    • 如果请求写入的数据的字节数小于或等于PIPE_BUF(一个Posix限制值),那么write操作保证是原子的。这意味着,如果有两个进程差不多同时往同一个管道或FIFO写,那么或者先写入来自第一个进程的所有数据,再写入来自第二个进程的所有数据,或者颠倒过来。系统不会互相混杂来自这两个进程的数据。然而,如果请求写入的数据的字节数大于PIPE_BUF,那么write操作不能保证是原子的。(Posix.1要求PIPE_BUF至少为512字节)
    • O_NONBLOCK标志的设置对write操作的原子性没有影响–原子性完全由所请求字节数是否小于等于PIPE_BUF决定的。然而当一个管道或FIFO设置成非阻塞时,来自write的返回值取决于待写的字节数以及该管道或FIFO中当前可用空间的大小。
      • 如果待写的字节数小于等于PIPE_BUF:
        • 如果该管道或FIFO中有足以存放所请求字节数的空间,那么所有数据字节都写入
        • 如果该管道或FIFO中没有足以存放所请求字节数的空间,那么立即返回一个EAGAIN错误。既然设置了O_NONBLOCK标志,调用进程就不希望自己被投入睡眠中。但是内核无法在接受部分数据的同时仍保证write操作的原子性,于是它必须返回一个错误告诉调用进程以后再试
      • 如果待写的字节数大于PIPE_BUF;
        • 如果该管道或FIFO中至少有1字节空间,那么内核写入该管道或FIFO能容纳数目的数据字节,概述同时作为来自write的返回值
        • 如果该管道或FIFO已满,那么立即返回一个EAGAIN错误
    • 如果向一个没有为读打开着的管道或FIFO写入,那么内核将产生一个SIGPIPE信号:
      • 如果调用进程既没有捕获也没有忽略该SIGPIPE信号,所采取的默认行为就是终止该进程
      • 如果调用进程忽略了该SIGPIPE信号,或者捕获了该信号并从其信号处理程序中返回,那么write返回一个EPIPE错误

1.5 字节流与消息

  • 到此为止所给出的使用管道和FIFO的例子都使用字节流I/O模型,这是Unix的原生I/O模型。这种模型不存在记录边界,也就是说读写操作根本不检查数据。

  • 举例来说,从某个FIFO中读出100个字节的进程无法判定往该FIFO中写入这100个字节的进程执行了单个100字节的写操作,5个20字节的写操作,2个50字节的写操作还是另外某种总共为100字节的写操作的组合。一个进程往该FIFO中写入55个字节后,另一个进程再写入45字节,这样的情况同样是可能的。

  • 这样的数据是一个字节流(byte stream),系统不对它作解释。如果需要某种解释,写进程和读进程就得先验地同意这种解释,并亲自去做。(由原因推结果)

  • 有时候应用希望对所传送的数据加上某种结构。当数据由长度可变消息构成,并且读出者必须知道这些消息的边界以判定何时已读出单个消息时,这种需求可能发生。下面三种技巧经常用于这个目的:

    • 带内特殊终止序列:许多Unix应用程序使用换行符来分隔消息。写进程会给每个消息添加一个换行符,读进程则每次读出一行。这种技巧一般要求数据中任何出现分隔符处都作转义处理(也就是说以某种方式把它们标志成数据,而不是作为分隔符)。许多因特网应用程序(FTP, SMTP, HTTP, NNTP)使用由一个回车符后跟一个换行符构成双字符序列(CT/LF)来分隔文本记录
    • 显式长度:每个记录前冠以它的长度。我们将马上使用这种技巧。当用在TCP上时,Sun RPC也使用这种技巧。这种技巧的优势之一是不再需要通过转义出现在数据中的分隔符,因为接收者不必扫描整个数据以寻找每个记录的结束位置
    • 每次连接一个记录:应用通过关闭与其对端的连接(网络应用时为TCP连接,IPC应用时为IPC连接)来指示一个记录的结束,。这要求为每个记录创建一个新连接,HTTP1.0就使用这一技术
  • 也可以更见更为结构化的消息,这种能力是由Posix消息队列和System V消息队列提供的。我们将看到每个消息有一个长度和一个优先级(System V成后者为类型)。长度和优先级是由发送者指定的,消息被读出后,这两者都返回给读出者。每个消息是一个记录(record),类似于UDP数据报

1.6 管道和FIFO限制

  • 系统加于管道和FIFO的唯一限制为:
    • OPEN_MAX 一个进程在任意时刻打开的最大描述符(Posix要求至少为16)
    • PIPE_BUF 可原子地写往一个管道或FIFO的最大数据量(Posix要求至少为512)
  • 我们马上会看到OPEN_MAX的值可通过调用sysconf函数查询。它通常可通过执行ulimit命令或limit命令从shell中修改,它也可以通过调用setrlimit函数从一个进程中修改
  • PIPE_BUF的值通常定义在<limits.h>头文件中,但是Posix认为它是一个路径名变量(pathname variable)。这意味着它的值可以随所指定的路径名而变化(只对FIFO而言,因为管道没有名字),因为不同的路径名可以落在不同的文件系统上,而这些文件系统可能有不同的特征。于是PIPE_BUF的值可在运行时通过调用pathconf或fpathconf取得

1.7 小结

  • 管道和FIFO,是许多应用程序的基本构建模块。管道普遍用于shell中,不过也可以从程序中使用,往往是用于从子进程向父进程回传消息。使用管道时涉及的某些代码(pipe, fork, close, exec, waitpid)可通过使用popen和pclose来避免,由它们处理具体细节并激活一个shell
  • FIFO与管道类似,但是它们使用mkfifo创建的,之后需要open打开。打开管道时必须小心,因为有许多规则制约着open的阻塞与否
  • 管道和FIFO的特征之一,是它们的数据是一个字节流,类似于TCP连接。把这种字节流分隔成各个记录的任何方法都得由应用程序来实现

Posix消息队列

1.1 概述

  • 消息队列可认为是一个消息链表。有足够写权限的线程可往队列中放置消息,有足够读权限的线程可从队列中取走消息。每个消息都是一个记录,它由发送者赋予一个优先级。在某个进程往一个队列写入消息之前,并不需要另外某个进程在该队列上等待消息的到达。这跟管道和FIFO是相反的,对于后者来说,除非读出者已存在,否则先有写入者是没有意义的。
  • 一个进程可以往某个队列写入一些消息,然后终止,再让另外一个进程在以后某个时刻读出这些消息。我们说过消息队列具有随内核的持续性,这跟管道和FIFO不一样。我们在第四章中说过,当一个管道或FIFO的最后一次关闭发生时,仍在该管道或FIFO上的数据将被丢弃

1.2 mq_open, mq_close, mq_unlink函数

  • mq_open函数,创建一个新的消息队列或打开一个已存在的消息队列

  • 声明:

    • #include <mqueue.h>
    • mqd_t mq_open(const char *name, int oflag, ... /* mode_t mode, struct mq_attr *attr*/);
  • 在2.2节描述过有关name参数的规则

  • oflag参数,是O_RDONLY, O_WRONLY, O_RDWR之一,可能按位或上O_CREAT, O_EXCL, O_NONBLOCK

  • 当实际操作是创建一个新队列时(已指定O_CREAT标志,且所请求的消息队列尚未存在),mode和attr参数是需要的。attr参数,用于给新队列指定某些属性。如果它为空指针,那就使用默认属性

  • mq_open的返回值,称为消息队列描述符(message queue descriptor),但它不必是(而且很可能不是)像文件描述符或套接字描述符这样的短整数。这个值用作其余7个消息队列函数的第一个参数

  • 已打开的消息队列是由mq_close关闭的

  • 声明:

    • #include <mqueue.h>
    • int mq_close (mqd_t mqdes);
  • 其功能与关闭一个已打开的close函数类似:调用进程可以不再使用该描述符,但其消息队列并不从系统中删除。一个进程终止时,它的所有打开着的消息队列都关闭,就像调用了mq_close一样

  • 要从系统中删除用作mq_open的第一个参数的某个name,必须调用mq_unlink

  • 声明:

    • #include <mqueue.h>
    • int mq_unlink(const char *name);
  • 每个消息队列有一个保存其当前打开着描述符数的引用计数器(就像文件一样),因而本函数能够实现类似于unlink函数删除一个文件的机制:当一个消息队列的引用计数仍大于0时,其name就能删除,但是该队列的析构(这与从系统中删除其名字不同)要到最后一个mq_close发生时才进行

  • Posix消息队列至少具备随内核的持续性。这就是说,即使当前没有进程打开着某个消息队列,该队列及其上的各个消息也将一直存在,直到调用mq_unlink并让它的引用计数达到0以删除该队列为止

1.3 mq_getattr和mq_setattr函数

  • 每个消息队列有四个属性,mq_getattr返回所有这些属性,mq_setattr则设置其中某个属性
  • 声明:
    • #include <mqueue.h>
    • int mq_getattr (mqd_t mqdes, struct mq_attr *attr);
    • int mq_setattr (mqd_t mqdes, const struct mq_attr *attr, struct mq_attr *oattr);
  • mq_attr结构含有以下属性:
    1
    2
    3
    4
    5
    6
    struct mq_attr {
    long mq_flags; /* message queue flag: 0, O_NONBLOCK */
    long mq_maxmsg; /* max number of messages allowed on queue */
    long mq_msgsize; /* max size of a message (in bytes) */
    long mq_curmsgs; /* number of messages currently on queue */
    };
  • mg_getattr把所指定队列的当前属性填入由attr指向的结构
  • mg_setattr给所指定队列设置属性,但是只使用由attr指向的mq_attr结构的mq_flags成员,以设置或清除非阻塞标志。该结构的另外三个成员被忽略:
    • 每个队列的最大消息数和每个消息的最大字节数只能在创建队列时设置
    • 队列中的当前消息数则只能获取而不能设置

1.4 mq_send和mq_receive函数

  • 这两个函数分别用于往一个队列中放置一个消息和从一个队列中取走一个消息。每个消息有一个优先级,它是一个小于MQ_PRIO_MAX的无符号整数。Posix要求这个上限至少为32
  • 声明:
    • #include <mqueue.h>
    • int mq_send(mqd_t mqdes, const char *ptr, size_t len, unsigned int prio);
    • ssize_t mq_receive(mqd_t mqdes, char *ptr, size_t len, unsigned int *priop);
  • mq_receive总是返回所指定队列中最高优先级的最早消息,而且该优先级能随该消息的内容及其长度一同返回
  • 这两个函数的前三个参数分别与write和read的前三个参数类似
  • mq_receive的len参数的值不能小于能加到所指定队列中的消息的最大大小(该队列mq_attr结构的mq_msgsize成员)。要是len小于该值,mq_receive就立即返回EMSGSIZE错误。
    • 这意味着使用Posix消息队列的大多数应用程序必须在打开某个队列后调用mq_getattr确定最大消息大小,然后分配一个或多个那样大小的读缓冲区。通过要求每个缓冲区总是足以存放队列中的任意消息,mq_receive就不必返回消息是否大于缓冲区的通知
  • mq_send的prio参数是待发送消息的优先级,其值必须小于MQ_PRIO_MAX。如果mq_receive的priop参数是一个非空指针,所返回消息的优先级就通过该指针存放。如果应用不必使用优先级不同的消息,那就给mq_send指定值为0的优先级,给mq_receive指定一个空指针作为其最后参数
  • 待发送消息的大小和优先级必须作为命令行参数指定。所用缓冲区使用calloc分配,该函数会把该缓冲区初始化为0

1.5 消息队列限制

  • 我们已遇到任意给定队列的两个限制,它们都是在创建该队列时建立的

    • mq_maxmsg 队列中的最大消息数
    • mq_msgsize 给定消息的最大字节数
  • 消息队列的实现定义了另外两个限制:

    • MQ_OPEN_MAX 一个进程能够同时拥有的打开着消息队列的最大数目(Posix要求它至少为8)
    • MQ_PRIO_MAX 任意消息的最大优先级值加1(Posix要求它至少为32)
  • 这两个常值往往定义在<unistd.h>头文件中,也可以在运行时通过调用sysconf函数获取

1.6 mq_notify函数

  • 第六章中讨论的System V消息队列的问题之一,是无法通知一个进程何时在某个队列中放置了一个消息。

  • 我们可以阻塞在msgrcv调用中,但那将阻止我们在等待期间做其他任何事。如果给msgrcv指定非阻塞标志(IPC_NOWAIT),那么尽管不阻塞了,但必须持续调用该函数以确定何时有一个消息到达。我们说过着称为轮询(polling),是对CPU时间的一种浪费。我们需要一种方法,让系统告诉我们何时有一个消息放置到了先前为空的某个队列中

  • Posix消息队列允许异步事件通知(asynchronous event notification),以告知何时有一个消息放置到了某个空消息队列中。这种通知有两种方式可供选择:

    • 产生一个信号
    • 创建一个线程来执行一个指定的函数
  • 这种通知通过调用mq_notify建立

  • 声明:

    • #include <mqueue.h>
    • int mq_notify (mqd_t mqdes, const struct sigevent *notification);
  • 该函数为指定队列建立或删除异步事件通知。sigevent结构是随Posix.1实时信号新加的。该结构以及本章中引入的所有新的信号相关的常值都定义在<signal.h>头文件中

  • 一些普遍适用于该函数的若干规则

    • 如果notification参数非空,那么当前进程希望在有一个消息到达所指定的先前为空的队列时得到通知。我们说:该进程被注册为接收该队列的通知
    • 如果notification参数为空指针,而且当前进程目前被注册为接收所指定队列的通知,那么已存在的注册将被撤销
    • 任意时刻只有一个进程可以被注册为接收某个给定队列的通知
    • 当有一个消息到达某个先前为空的队列,而且已有一个进程被注册为接收该队列的通知时,只有在没有任何线程阻塞在该队列的mq_receive调用中的前提下,通知才会阿初。这就是说,在mq_receive调用中的阻塞比任何通知的注册都优先
    • 当该通知被发送给它的注册进程时,其注册即被撤销。该进程必须再次调用mq_notify以重新注册(如果想要的话)
  • 更为简易(并且可能更为高效)的办法之一,是阻塞在某个函数中,仅仅等待该信号的递交,而不是让内核执行一个只为设置一个标志的信号处理程序。sigwait提供了这种能力

  • 声明:

    • #include <signal.h>
    • int sigwait (const sigset_t *set, int *sig);
  • 调用sigwait前,我们阻塞某个信号集。我们将这个信号集指定为set参数。sigwait然后一直阻塞到这些信号中有一个或多个待处理,这时它返回其中一个信号。该信号值通过指针sig存放,函数的返回值则为0.这个过程称为:同步地等待一个异步事件。我们是在使用信号,但没有涉及异步信号处理程序

1.7 实时信号

  • 在过去几十年中,Unix信号经历了多次重大的演变。信号可划分为两个大组
    • 其值在SIGRTMIN和SIGRTMAX之间(包括两者在内)的实时信号。Posix要求至少提供PTSIG_MAX这种实时信号,而该常值的最小值为8
    • 所有其他信号:SIGALRM, SIGINT, SIGKILL,等等

1.8 小结

  • Posix消息队列比较简单:

    • mq_open创建一个新队列或打开一个已存在的队列
    • mq_close关闭队列
    • mq_unlink则删除队列名。
    • 往一个队列中放置消息使用mq_send,从一个队列中读出消息使用mq_receive。
    • 队列属性的查询与设置使用mq_getattr和mq_setattr,
    • 函数mq_notify则允许我们注册一个信号或线程,它们在有一个消息被放置到某个空队列上时发送(信号)或激活(线程)。队列中的每个消息被赋予一个小整数优先级,mq_receive每次被调用时总是返回最高优先级的最早消息
  • rnq_notify的使用给我们引入了Posix实时信号,它们在SIGRTMIN和SIGRTMAX之间。当设置SA_SIGINFO标志来安装这些信号的处理程序时

    • 这些信号是排队的
    • 排了队的信号是以FIFO顺序递交的
    • 给信号处理程序传递两个额外的参数
  • 最后,使用内存映射I/O以及一个Posix互斥锁和一个Posix条件变量,以约500行C代码实现了Posix消息队列的大多数特性。该实现展示了处理新队列的创建中存在的一个竞争状态

互斥锁和条件变量

1.1 概述

  • 从本章开始关于同步的讨论:怎样同步多个线程或多个进程的活动。为允许在线程或进程间共享数据,同步通常是必需的。互斥锁和条件变量是同步的基本组成部分
  • 互斥锁和条件变量出自Posix.1线程标准,它们总是可用来同步一个进程内的各个线程的。如果一个互斥锁或条件变量存放多个进程间共享的某个内存区中,那么Posix还允许它用于这些进程间的同步

1.2 互斥锁:上锁与解锁

  • 互斥锁指代互相排斥(mutual exclusion),它是最基本的同步形式。互斥锁用于保护临界区(critical region),以保证任何时刻只有一个线程在执行其中的代码(假设互斥锁由多个线程共享),或者任何时刻只有一个进程在执行其中的代码(假设互斥锁由多个进程共享)

1.3 对比上锁与等待

  • 现在展示互斥锁用于上锁(locking)而不能用于等待(waiting)

1.4 条件变量:等待与信号发送

  • 互斥锁用于上锁,条件变量则用于等待。这两种不同类型的同步都是需要的
  • 条件变量是类型为pthread_cond_t的变量,以下两个函数使用了这些变量:
    • #include <pthread.h>
    • int pthread_cond_wait(pthread_cond_t *cptr, pthread_mutex_t *mptr);
    • int pthread_cond_singal(pthread_cond_t *cptr);
  • 这两个函数所等待或由之得以通知的条件,其定义由我们选择:我们在代码中测试这种条件

1.5 小结

  • 互斥锁用于保护代码临界区,从而保证任何时刻只有一个线程在临界区内执行。有时候一个线程获得某个互斥锁后,发现自己需要等待某个条件变为真。如果是这样,该线程就可以等待在某个条件变量上。条件变量总是有一个互斥锁与之关联。把调用线程投入睡眠的pthread_cond_wait函数在这么做之前先给所关联的互斥锁解锁,以后某个时刻唤醒该线程前再给该互斥锁上锁。该条件变量由另外某个线程向它发送信号,而这个发送信号的线程即可以只唤醒一个线程(pthread_cond_signal),也可以唤醒等待相应条件变为真的所有线程(pthread_cond_broadcast)

  • 互斥锁和条件变量可以静态分配并静态初始化。它们也可以动态分配,那要求动态地初始化它们。动态初始化允许我们指定进程间共享属性,从而允许在不同进程间共享某个互斥锁或条件变量,其前提是该互斥锁或条件变量必须存放在由这些进程共享的内存区中

读写锁

记录上锁

1.1 小结

  • fcntl记录上锁提供了对一个文件的劝告性或强制性上锁功能,而我们是通过该文件打开着的描述符来访问它的。这些锁用于不同进程间的上锁,而不是同一进程内不同线程间的上锁。
  • 术语“记录”是个不确切的名字,因为Unix内核没有文件内记录的概念。更好的称谓是“范围上锁(range locking)”,因为我们上锁或解锁的是文件内的一个字节范围。这类记录上锁几乎都用作写作进程之间的劝告性锁,因为即使强制性上锁也会导致不一致数据

Posix信号量

1.1 概述

  • 信号量(semaphore),是一种用于提供不同进程间或一个给定进程的不同线程间同步手段的原语。本书讨论三种类型的信号量:
    • Posix有名信号量:使用Posix IPC名字标识,可用于进程或线程间的同步
    • Posix基于内存的信号量:存放在共享内存区中,可用于进程或线程间的同步
    • System V信号量:在内核中维护,可用于进程或线程间的同步

1.2 小结

  • Posix信号量是计数信号量,它提供以下三种基本操作:
    • 创建一个信号量;
    • 等待一个信号量的值变为大于0,然后将它的值减一;
    • 给一个信号量的值加1,并唤醒等待该信号量的任意线程,以此挂出该信号量
  • Posix信号量可以是有名的,也可以是基于内存的。有名信号量总是能够在不同进程间共享,基于内存的信号量则必须在创建时指定成是否在进程间共享。这两类信号量的持续性也有差别,有名信号量至少有随内核的持续性,基于内存的信号量则具有随进程的持续性

System V信号量

共享内存区介绍

1.1 概述

  • 共享内存区是可用IPC形式中最快的。一旦这样的内存区映射到共享它的进程的地址空间,这些进程间数据的传递就不再涉及内核。然而往该共享内存区存放消息或从中取走消息的进程间通常需要某种形式的同步。我们在第三部分讨论了各种形式的同步:互斥锁,条件变量,读写锁,记录锁,信号量

  • 这里说的“不再涉及内核”的含义是:进程不再通过执行任何进入内核的系统调用来彼此传递数据。显然,内核必须建立允许各个进程共享该内存区的内存映射关系,然后一直管理该内存区

  • 考虑用来传递各种类型消息的一个实例客户-服务器文件复制程序中涉及的通常步骤:

    • 服务器从输入文件读。该文件的数据由内核读入自己的内存空间,然后从内核复制到服务器进程
    • 服务器往一个管道,FIFO或消息队列以一条消息的形式写入这些数据。这些IPC形式通常需要把这些数据从进程复制到内核
    • 客户从该IPC通道读出这些数据,这通常需要把这些数据从内核复制到进程
    • 最后,将这些数据从由write函数的第二个参数指定的客户缓冲区复制到输出文件
  • 这里通常需要总共四次数据复制。而且这四次复制是在内核和某个进程间进行的,往往开销很大。

  • 这些IPC形式(管道,FIFO和消息队列)的问题在于,两个进程要交换信息时,这些信息必须经由内核传递

  • 通过让两个或多个进程共享一个内存区,共享内存区这种IPC形式提供了绕过上述问题的办法。当然,这些进程必须协调或同步对该共享内存区的使用。第三部分讲述的任何技巧都可用于这样的同步目的。前面的客户-服务器例子现在涉及的步骤如下:

    • 服务器使用(例如)一个信号量取得访问某个共享内存区对象的权力
    • 服务器将数据从输入文件读入到该共享内存区对象。read函数的第二个参数所指定的数据缓冲区地址指向这个共享内存区对象
    • 服务器读入完毕时,使用一个信号量通知客户
    • 客户将这些数据从该共享内存区对象写出到输入文件中

1.2 mmap, munmap和msync函数

  • mmap函数,把一个文件或一个Posix共享内存区对象映射到调用进程的地址空间。使用该函数有三个目的:

    • 使用普通文件以提供内存映射I/O
    • 使用特殊文件以提供匿名内存映射
    • 使用shm_open以提供无亲缘关系进程间的Posix共享内存区
  • 声明:

    • #include <sys/mman.h>
    • void *mmap(void *addr, size_t len, int prot, int flags, int fd, off_t offset);
  • 其中addr可以指定描述符fd应被映射到的进程内空间的起始地址。它通常被指定为一个空指针,这样告诉内核自己去选择起始地址。无论哪种情况下,该函数的返回值都是描述符fd所映射到内存区的起始地址

  • len是映射到调用进程地址空间中的字节数,它从被映射文件开头起第offset个字节处开始算。offset通常设置为0

  • 内存映射区的保护由prot参数指定。该参数的常见值是代表读写访问的PROT_READ | PROT_WRITE

    • PROT_READ 数据可读
    • PROT_WRITE 数据可写
    • PROT_EXEC 数据可执行
    • PROT_NONE 数据不可访问
  • flags使用常值指定。MAP_SHARED或MAP_PRIVATE这两个标志必须指定一个,并可有选择地或上MAP_FIXED

    • 如果指定了MAP_PRIVATE,那么调用进程对被映射数据所作的修改只对该进程可见,而不改变其底层支撑对象(或者是一个文件对象,或者是一个共享内存区对象)
    • 如果指定了MAP_SHARED,那么调用进程对被映射数据所作的修改对于共享该对象的所有基础南横都可见,而且确实改变了其底层支撑对象
  • 为从某个进程的地址空间删除一个映射关系,我们调用munmap

  • 声明:

    • #include <sys/mman.h>
    • int munmap(void *addr, size_t len);
  • 其中addr参数是由mmap返回的地址,len是映射区的大小

  • 如果我们修改了处于内存映射到某个文件的内存区中某个位置的内容,那么内核将在稍后某个时刻相应地更新文件。然而有时候我们希望确信硬盘上的文件内容与内存映射去中的内容一致,于是调用msync来执行这种同步

  • 声明:

    • #include <sys/mman.h>
    • int msync (void *addr, size_t len, int flags);
  • 其中addr和len参数通常指代内存中的整个内存映射区,不过也可以指定该内存区的一个子集

  • flags参数是常值的组合

    • MS_ASYNC 执行异步写
    • MS_SYNC 执行同步写
    • MS_INVALIDATE 使高速缓存的数据失效
    • MS_ASYNC和MS_SYNC这两个常值中必须指定一个,但不能都指定。它们的差别使,一旦写操作已由内核排入队列,MS_ASYNC即返回,而MS_SYNC则要等到写操作完成后才返回。如果还指定了MS_INVALIDATE,那么与其最终副本不一致的文件数据的所用内存中副本都失效,后续的引用将从文件中读取数据
  • 我们如此详尽地讨论mmap的理由有两个:

    • 一是文件的内存映射是一种很有用的技巧
    • 二是Posix共享内存区也使用mmap
  • Posix还定义了处理内存管理的四个额外函数:

    • mlockall函数会使调用进程的整个内存空间常驻内存。munlockall则撤销这种锁定
    • mlock会使调用进程地址空间的某个指定范围常驻内存,该函数的参数指定了这个范围的起始地址以及从该地址算起的字节数。munlock则撤销某个指定内存区的锁定

Posix共享内存区

1.1 概述

  • Posix.1提供了两种在无亲缘关系进程间共享内存区的方法:
    • 内存映射文件(memory-mapped file):由open函数打开,由mmap函数把得到的描述符映射到当前进程地址空间中的一个文件
    • 共享内存区对象(shared-memory object):由shm_open打开一个Posix.1 IPC名字(也许是在文件系统中的一个路径名),所返回的描述符由mmap函数映射到当前进程的地址空间
  • 这两种技术都需要调用mmap,差别在于作为mmap的参数之一的描述符的获取手段:通过open或通过shm_open。Posix把两者合称为内存区对象(memory object)

System V 共享内存区

1.1 概述

  • 当讨论客户-服务器情形和过程调用时,存在着三种不同类型的过程调用:

    • 本地过程调用(local procedure call)。被调用的过程(函数)与调用过程处于同一个进程中。典型的情况是,调用者通过执行某条机器指令把控制传给新进程,被调用过程保存机器寄存器的值,并在栈顶分配存放其本地变量的空间
    • 远程过程调用(remote procedure call, 简称RPC):被调用过程和调用过程处于不同的进程中。我们通常称调用者为客户,称被调用者的过程为服务器。门(door)所提供的能力:一个进程调用同一台主机上另一个进程中的某个过程(函数)。通过给本进程内的某个过程创建一个门,一个进程(服务器)就使得该过程能为其他进程(客户)所调用
    • RPC通常允许一台主机上的某个客户调用另一台主机上的某个服务器过程。只要这两台主机以某种形式的网络连接着
  • 本地过程调用是同步的:调用者直到被调用过程返回后才重新获得控制。

  • 线程可认为是提供了某种形式的异步过程调用:有一个函数被调用(pthread_create的第三个参数),该函数和调用者看起来在同步执行。调用者可通过调用pthread_join等待这个新线程的完成。

  • 远程过程调用可能是同步的,也可能是异步的,不过我们将看到门调用是同步的

  • 在进程(客户或服务器)内部,门是用描述符标识的。在进程以外,门可能是用文件系统中路径名标识的。

  • 一个服务器通过调用door_create()创建一个门,传递给该函数的参数是将与该门关联的过程的一个指针,该函数的返回值是新创建的门的一个描述符。

  • 该服务器然后通过调用fattach给这个门描述符关联一个路径名。一个客户通过调用open来打开一个门,传递给该函数的参数是该门的服务器关联在其上的路径名,该函数的返回值是本客户访问该门的描述符。该客户然后通过调用door_call调用服务器过程。自然,某个门的服务器可以是另一个门的客户

1.2 door_create函数

  • 服务器进程通过调用door_create建立一个服务器过程
  • 声明:
    • #include <door.h>
    • typedef void Door_server_proc(void *cookie, char *dataptr, size_t datasize, door_desc_t *descptr, size_t ndesc);
    • int door_create(DOor_server_proc *proc, void *cookie, u_int attr);
  • 在上述的声明中,我们加上了自己的typedef,这样简化了服务器过程的函数原型。这个typedef语句说,们服务器过程是以五个参数调用的,不返回任何值

1.3 door_return函数

  • 服务器过程完成工作时通过调用door_return返回。这会使客户中相关联的door_call调用返回
  • 声明
    • #include <door.h>
    • int door_return(char *dataptr, size_t datasize, door_desc_t *descptr, size_t *ndesc);
  • 数据结果由dataptr和datasize指定,描述符结果由descptr和ndesc指定

1.4 door_cred函数

  • 门有一个很不错的特性:服务器过程能够获取每个调用对应的客户凭证。这是由door_cred函数完成的。
  • 声明:
    • #include <door.h>
    • int door_cred(door_cred_t *cred);
  • 其中由cread指向的door_cred_t结构将在返回时填入客户的凭证

1.5 door_info函数

  • 我们刚才描述的door_cred函数给服务器提供关于客户的信息。客户也可通过调用door_info函数找出有关服务器的信息
  • 声明
    • #include <door.h>
    • int door_info(int fd, door_info_t *info);
  • 其中fd指定一个已打开的门。由info指向的door_info_t结构将在返回时填入关于服务器的信息

1.6 小结

  • 门提供了调用同一台主机上另一个进程中某个过程的能力。下一章中我们将对这种远程过程调用概念加以扩展,讲述如何调用另一台主机上另一个过程中的某个进程
  • 基本的API函数比较简单。服务器调用door_create创建一个门,并给他关联一个服务器过程,然后调用fattach给该门附接一个文件系统中的路径名。客户对该路径名调用open,然后调用door_call以调用服务器进程中的服务器进程。该服务器过程通过调用door_return返回
  • 门允许从客户向服务器以及从服务器向客户传递描述符。这是一个非常有用的技巧,因为Unix中描述符代表着许多访问手段:
    • 访问文件以进行文件或设备I/O
    • 访问套接字或XTI以进行网络通信
    • 访问门以进行远程过程调用

Sun RPC

1.1 概述

  • 构筑一个应用程序时,我们首先在以下两者之间做出选择:

    • 构建一个庞大的单一程序,完成全部工作
    • 把整个应用程序散步在彼此通信的多个进程中
  • 如果我们选择后者,接下去的抉择是:

    • 假设所有进程运行在同一台主机上(允许IPC用于这些进程间的通信);
    • 假设某些进程会运行在其他主机上(要求使用进程间某种形式的网络通信)
  • 本书的大部分关注的是:使用消息传递,共享内存区,并可能使用某种形式的同步来进行同一台主机上的进程间IPC。同一进程内不同线程间的IPC以及不同进程内各个线程间的IPC只是这种情况的特殊情况

  • 不同部分之间需要网络通信的应用程序大多数是使用显示网络编程(explicit network programming)方式编写的,也就是直接调用套接字API或XTI API。使用套接字API时,客户调用socket,connect,read和write;服务器则调用socket,bind,listen,accept,read和write。我们熟悉的大多数应用程序(Web浏览器,web服务器,Telnet客户,Telnet服务器等程序)就是以这种方式编写的。

  • 编写分布式应用程序的另一种方式是使用隐式网络编程(implicit network programming)。远程过程调用(RPC)提供了这样的一个工具

1.2 小结

  • Sun RPC允许我们编写分布式应用程序,让客户运行在一台主机上,服务器运行在另一台主机上。

后记

1.1

  • 本书详细讲述了用于进程间通信(IPC)的四种不同技术:

    • 消息传递(管道,FIFO,Posix和System V消息队列)
    • 同步(互斥锁,条件变量,读写锁,文件和记录锁,Posix和System V信号量)
    • 共享内存区(匿名共享内存区,有名Posix共享内存区,有名System V共享内存区)
    • 过程调用(Solaris门,Sun RPC)
  • 消息队列和过程调用往往单独使用,也就是说它们通过提供了自己的同步机制。相反,共享内存区通常需要某种由应用程序提供的同步形式才能正确工作。同步技术有使用单独使用,也就是说不涉及其他形式的IPC

  • 讨论了共十六章的细节后,很显然的一个问题是:解决某个待定问题应使用哪种形式的IPC?

  • 遗憾的是不存在关于IPC的简单判定。Unix提供的类型如此之多的IPC表明,不存在解决全部(或者甚至于大部分)问题的单一方法。

  • 你能做的仅仅是:逐渐熟悉各种IPC形式提供的机制,然后根据特定应用的要求比较它们的特性

  • 我们首先列出必须考虑的四个前提,因为它们对于你的应用程序相当重要:

    • 连网的(networked)还是非连网的(nonnetworked)。我们假设已作出这个决定,IPC就是用于单台主机上的进程或线程间的。如果应用程序有可能散布堕胎主机上,那就考虑使用套接字代替IPC,从而简化以后向连网的应用程序转移的工作
    • 可移植性(portability)。几乎所有Unix系统都支持Posix管道,Posix FIFO和Posix记录上锁。几乎所有Unix系统都可使用Sun RPC,门则是Solaris特有的特性
    • 性能(performance)。如果性能是应用程序设计中的一个关键前提,那就在你自己的系统上运行附录A中开发的程序。更好的做法是,把这些程序修改称模拟待定应用的实际环境,再再这样的环境中测量它们的性能
    • 实时调度(realtime scheduling)。如果你的应用需要这一特性,而且你的系统支持Posix实时调度选项,那就考虑使用Posix的消息传递和同步函数(消息队列,信号量,互斥锁,条件变量)。举例来说,当某个线程挂出一个有多个线程阻塞在其上的信号量时,待解阻塞的线程是以一种适合于所阻塞线程的调度策略和参数的方式选择的。相反,System V信号量不能保证实时调度
  • 为了帮助理解各种类型IPC的一些特性和局限,我们汇总了它们的一些主要差异:

    • 管道和FIFO是字节流,没有消息边界。Posix消息和System V消息则有从发送者向接收者维护的记录边界。(TCP是没有记录边界的字节流,UDP则提供具有记录边界的消息)
    • 当有一个消息放置到一个空队列中时,Posix消息队列可向一个进程发送一个信号,或者启动一个新的线程。System V
    • 消息队列不提供类似的通知形式。这两种消息队列都不能直接跟select和poll一起使用,不过我们分别在图5-14和6.9节中提供了间接的方法
    • 管道或FIFO中的数据字节是先进先出的。Posix消息和System V消息具备由发送者赋予的优先级。从一个Posix消息队列读出消息时,首先返回的总是具有最高优先级的消息。从一个System V消息队列读出时,读出者可以请求所想要的任意优先级的消息
    • 当有一个消息放置到一个Posix或System V消息队列,或者写到一个管道胡FIFO时,只有一个副本递交给刚好一个线程。这些IPC形式不存在窥探能力(即类似于套接字API的MSG_PEEK标志,UNPV1的13.7节),它们的消息不能广播或多播到多个接收者(这对于使用UDP协议的套接字程序和XTI程序时可能的,UNPv1第十八章和第十九章
    • 互斥锁,条件变量和读写锁都是无名的,也就是说,它们是基于内存的。它们能够很容易地在单个进程内的不同线程间共享。然而只有当它们存放在不同进程间共享的内存区中时,它们才有可能为这些进程所共享。而Posix信号量就有两种形式:有名的和基于内存的。有名信号量总能在不同进程间共享(因为它们是用Posix IPC名字标识的),基于内存的信号量也能在不同进程间共享,条件是必须村房子这些进程间共享的内存区中。System V信号量也是有名的,不过所用的是key_t数据类型,它往往是从某个文件的路径名获取的。这些信号量能够很容易地在不同进程间共享
    • 如果持有某个锁的进程没有释放它就终止,内核就自动释放fcntl记录锁。System V信号量将这一特性作为一个选项提供。互斥锁,条件变量,读写锁和Posix信号量不具备该特性
    • 每个fcntl锁都与通过其相应描述符访问的文件中的某个字节范围(我们称之为一个“记录”)相关联。读写锁则不与任何类型的记录关联
    • Posix共享内存区和System V共享内存区都具有随内核的持续性。它们一直存在到被显式地删除为止,即使当前没有任何进程在使用它们也这样
    • Posix共享内存区对象的大小可在其使用期间扩张。System V共享内存区的大小则是在创建时固定下来的
    • System V IPC所存在的三种内核限制往往需要系统管理员对它们进行调整,因为它们的默认值通常不能满足现实应用的需要(3.8节)。Posix IPC所存在的三种内核限制则通常根本不需要调整
    • 有关System V IPC对象的信息(当前大小,属主ID,最后修改时间等等)可使用三个XXXctl函数的IPC_STAT命令获取,也可执行ipcs命令获取。有关Posix IPC对象的信息则不存在标准的获取方式。如果这些对象是用文件系统中的文件实现的,而且我们知道从Posix IPC名字到路径名的映射关系,那么这些对象的信息可使用stat函数或ls命令获取。但是如果这些对象不是使用文件实现的,那么可能获取不了这样的信息
    • 在众多的同步技术–互斥锁,条件变量,读写锁,记录锁,Posix信号量和System V信号量中,可从信号处理程序中调用的函数只有sem_post和fcntl
    • 在众多的消息传递技术:管道,FIFO,Posix消息队列和System V消息队列中,可从一个信号处理程序中调用的函数只有read和write(适用于管道和FIFO)
    • 在所有的消息传递技术中,只有门向服务器准确地提供了客户的标识(15.5节).在5.4节中我们提到过,另外两种消息传递类型也标识客户:BSD/OS在使用Unix域套接字时提供这种标识(UNPv1的15.2节),SVR4则在通过某个管道传递一个描述符时通过同一个管道传递发送者的标识

性能测量

1.1 概述

  • 本书讨论了六种类型的消息传递:

    • 管道
    • FIFO
    • Posix消息队列
    • System V消息队列
    • Sun RPC
  • 五种类型的同步

    • 互斥锁和条件变量
    • 读写锁
    • fcntl记录上锁
    • Posix信号量
    • System V信号量
  • 我们现在开发一些简单的程序来测量这些IPC类型的性能,这样有助于我们就何时该使用某种特定形式的IPC做出明智的决策

  • 比较不同形式的消息传递时,我们感兴趣的是以下两种测量尺度:

    • 带宽(bandwidth):数据通过IPC通道转移的速度。为测量该值,我们从一个进程向另一个进程发送大量数据(几百万字节)。我们还给不同大小的I/O操作测量该值,期待发现带宽随每个I/O操作数据量的增长而增长的规律
    • 延迟(latency):一个小的IPC消息从一个进程到另一个进程再返回所花的时间。我们测量的是只有一个字节的消息从一个进程到另一个进程再回来的时间(往返时间)
  • 在现实世界中,带宽高速我们大块数据通过一个IPC通道发送出去需花多长时间,然而IPC也由于传送小的控制信息,系统处理这些小消息所需的时间就由延迟提供。这两个数都很重要。

简介

  • 剑指Offer 书籍阅读笔记

双指针

  • 双指针是一种常用的解题思路,可以使用两个相反方向或者相同方向的指针扫描数组从而达到解题目的。

  • 指针,并不专指C语言中的指针,而是一个相对宽泛的概念,是能定位数据容器中某个数据的手段。在数组中它实际上是数字的下标

  • 方向相反的双指针经常用来求排序数组中的两个数字之和。一个指针P1指向数组的第一个数字,另一个指针P2指向数组的最后一个数字,然后比较两个指针指向的数字之和及一个目标值。如果两个指针指向的数字之和大于目标值,则向左移动指针P2;如果两个指针指向的数字之和小于目标值,则向右移动指针P1。此时,两个指针的移动方向是相反的。

  • 方向相同的双指针,通常用来求正数数组中子数组的和或者乘积。初始化的时候两个指针P1和P2都指向数组的第一个数字。如果两个指针之间的子数组的和或成绩大于目标值,则向右移动指针P1删除子数组最左边的数字;如果两个指针之间的子数组的和或乘积小于目标值,则向右移动指针P2在子数组的右边增加新的数字。此时两个指针的移动方向是相同的

  • 双指针,是解决与数组相关的面试题的一种常用技术。如果数组是排序的,那么应用双指针技术就能够用O(n)的时间在数组中找出两个和为给定值的数字。

  • 如果数组中的所有数字都是整数,那么应用双指针技术就可以用O(1)的辅助空间找出和为给定值的子数组

双指针 链表

  • 所谓双指针是指利用两个指着来解决与链表相关的面试题,这是一种常用思路。双指针思路又可以根据两个指针不同的移动方式细分为两种不同的方法。

  • 第一种方法是前后双指针,即一个指针在链表中提前朝着指向下一个节点的指针移动若干步,然后移动第二个指针。

    • 前后双指针的经典应用是查找链表的倒数第K个节点。先让第1个指针从链表的头结点开始朝着下一个节点的指针先移动K - 1步,然后让第二个指针指向链表的头节点,在让两个指针以相同的速度一起移动,当第一个指针达到链表的尾节点时,第二个指针正好指向倒数第K个节点
  • 第二种方法是快慢双指针,即两个指针在链表中移动的速度不一样,通常是快的指针朝着下一个节点的指针一次移动两步,慢的指针一次只移动一步。

    • 采用这种方法,在一个没有环的链表中,当块的指针到达链表尾节点的时候慢的指针正好指向链表的中间节点

简介

  • 深入理解计算机系统书籍的阅读,第一阶段为上下班阅读,拍下来需要记录的文字,回到公司整理到这里

第三章 程序的机器级表示

链接器

  • 链接器的任务之一就是为函数调用找到匹配的函数的可执行代码的位置

反汇编器(disassembler)

  • 要查看机器代码文件的内容,有一类称为反汇编器的程序非常有用。这些程序根据机器代码产生一种类似于汇编代码的格式
  • 在Linux系统中,带-d命令行标志的程序OBJDUMP表示(object dump)可以充当这个角色
  • 示例:
    1
    objdump -d mstore.o

C 指针

  • C语言中所谓的指针,其实就是地址。

  • 间接引用指针就是将该指针放在一个寄存器中,然后在内存引用中使用这个寄存器

  • 其次,像x这样的局部变量通常是保存在寄存器中,而不是内存中,访问寄存器比访问内存要快得多

C 指针运算

  • C语言允许对指针进行运算,而计算出来的值会根据该指针引用的数据类型的大小进行伸缩

  • 单操作数操作符 &* 可以产生指针和间接引用指针。也就是:

    • 对于一个表示某个对象的表达式Expr, &Expr是给出该对象地址的一个指针。
    • 对于一个表示地址的表达式AExpr, *AExpr给出该地址处的值
  • 因此,表达式Expr与 * &Expr是等价的。可以对数组和指针应用数据下标操作。

  • 数组引用A[i]等同于表达式 * (A + i)。它计算第 i 个数组元素的地址,然后访问这个内存位置

定长数组

  • 示例:

    1
    2
    #define N 16
    typedef int fix_matrix[N][N]
  • 当程序要用一个常数作为数组的维度或者缓冲区的大小时,最好通过 #define 声明将这个常数与一个名字联系起来,然后在后面一直使用这个名字代替常数的数值。

  • 这样一来,如果需要修改这个值,只用简单地修改这个 #define 声明就可以了

运行时栈

  • C语言过程调用机制的一个关键特性(大多数其他语言也是如此)在于使用了栈数据结构提供的后进先出的内存管理原则

  • 程序可以用栈来管理它的过程所需要的存储空间,栈和程序寄存器存放着传递控制和数据,分配内存所需要的信息。

异质的数据结构

  • C 语言提供了两种将不同类型的对象组合到一起创建数据类型的机制

    • 结构(structure),用关键字struct来声明,将多个对象集合到一个单位中
    • 联合(union),用关键字union声明,允许用几种不同的类型来引用一个对象
  • C语言的struct声明创建一个数据类型,将可能不同类型的对象聚合到一个对象中。用名字来引用结构的各个组成部分。

  • 类似于数据的实现,结构的所有组成部分都存放在内存中一段连续的区域内,而指向结构的指针就是结构第一个字节的地址

  • 编译器维护关于每个结构类型的信息,指示每个字段(field)的字节偏移。它以这些偏移作为内存引用指令中的位移,从而产生对结构元素的引用

数据对齐

  • 许多计算机系统对基本数据类型的合法地址做出了一些限制,要求某种类型对象的地址必须是某个值K(通常是2,4,8)的倍数。
  • 这种对齐限制简化了形成处理器和内存系统之间接口的硬件设计

栈随机化

  • 栈随机化的思想使得栈的位置在程序每次运行时都有变化。因此,即使许多机器都运行同样的代码,它们的站地址都是不同的。

  • 实现的方式:

    • 程序开始时,在栈上分配一段0-n字节之间的随机大小的空间,例如,使用分配函数alloca在栈上分配指定字节数量的空间。
    • 程序不适用这段空间,但是他会导致程序每次执行时后续的栈位置发生了变化。
    • 分配的范围n必须足够大,才能获得足够多的栈地址变化,但是又要足够小,不至于浪费程序太多的空间
  • 在Linux系统中,栈随机化已经变成了标准行为。它是更大的一类技术中的一种,这类技术称为地址空间布局随机化(Address-Space Layout Randomization),或者简称ASLR

  • 采用ASLR,每次运行时程序的不同部分,包括程序代码,库代码,栈,全局变量和堆数据,都会被加载到内存的不同区域。这就意味着在一台机器上运行一个程序,与在其他机器上运行同样的程序,他们的地址映射大相径庭。这样才能对抗一些形式的攻击

第三章 小结

  • 编译器C++与编译C非常相似。实际上,C++的早期实现就只是简单地执行了从C++到C的源到源的转换,并对结果运行C编译器,产生目标代码。C++的对象用结构来表示,类似于C的struct。C++的方法是用指向实现方法的代码的指针来表示的。

  • 相比而言,Java的实现方式完全不同。Java的目标代码是一种特殊的二进制表示,称为Java字节代码。这种代码可以看成是虚拟机的机器级程序。正如它的名字暗示的那样,这种机器并不是直接用硬件实现的,而是用软件解释器处理字节代码,模拟虚拟机的行为。

  • 另外,有一种称为及时编译(just-in-time compilation)的方法,动态地将字节代码序列翻译成机器指令。当代码要执行多次时(例如在循环中),这种方法执行起来更快。用字节代码作为程序的低级表示,优点是相同的代码可以在许多不同的机器上执行。

第五章 优化程序性能

理解现代处理器

  • 为了理解改进性能的方法,我们需要理解现代处理器的微体系结构。由于大量的晶体管可以被集成到一块芯片上,现在微处理器采用了复杂的硬件,试图使程序性能最大化。带来的一个后果就是处理器的实际操作与通过观察机器级程序所察觉到的大相径庭。
  • 在代码级上,看上去似乎试一次执行一条指令,每条指令都包括从寄存器或内存取值,执行一个操作,并把结果存回到一个寄存器或内存位置。在实际的处理器中,是同时对多条指令求值的,这个现象称为 指令级并行

应用: 性能提高技术

  • 优化程序性能的基本策略:

    • 高级设计。为遇到的问题选择适当的算法和数据结构。要特别警觉,避免使用那些会渐进地产生糟糕性能的算法或编码技术
  • 基本编码原则:避免限制优化的因素,这样编译器就能产生高效的代码

    • 消除连续的函数调用。在可能时,将计算移到循环外。考虑有选择地妥协程序的模块性以获取更大的效率
    • 消除不必要的内存引用。引入临时变量来保存中间结果。只有在最后的值计算出来时,才将结果存放到数组或全局变量中
  • 低级优化:结构化代码以利用硬件功能

    • 展开循环,降低开销,并且使得进一步的优化成为可能
    • 通过使用例如多个累积变量和重新结合等技术,找到方法提高指令级并行
    • 用功能性的风格重写条件操作,使得编译采用条件数据传送

程序剖析 code profiler

  • 程序剖析运行程序的一个版本,其中插入了工具代码,以确定程序的各个部分需要多少时间。这对于确认程序中我们需要集中注意力优化的部分是很有用的。

  • 剖析的一个有力之处在于可以在现实的基准数据(benchmark data)上运行实际程序的同时,进行剖析

  • Unix系统提供了一个剖析程序GPROF。这个程序产生两种形式的信息。

    • 首先,它确定程序中每个函数花费了多少CPU时间
    • 其次,它计算每个函数被调用的次数,以执行调用的函数来分类
  • 这两种形式的信息都非常有用。这些计时给出了不同函数在确定整体运行时间中的相对重要性。调用信息使得我们能够理解程序的动态行为

  • 用GPROF进行剖析需要三个步骤,就像C程序prog.c所示,它运行时命令参数为file.txt:

    • 首先,程序必须为剖析而编译和链接。使用GCC(以及其他C编译器),就是在命令行上简单地包括运行时标志-pg。确保编译器不通过内联替换来尝试执行任何优化是很重要的,否则就可能无法正确刻画函数调用。我们使用优化标志-Og,以保证能正确跟踪函数调用
      • linux> gcc -Og -pg prog.c -o prog
    • 其次,程序像往常一样执行:
      • linux> ./prog file.txt
      • 它运行得会比正常时稍微慢一点(大约慢两倍),不过除此之外唯一的区别就是它产生了一个文件gmon.out
    • 调用GPROF来分析gmon.out中的数据
      • linux> gprof prog
  • 剖析报告的第一部分列出了执行各个函数花费的时间,按照降序排列。

  • 每一行代表对某个函数的所有调用所花费的时间

    • 第一列表明花费在这个函数上的时间占整个时间的百分比
    • 第二列显示的是直到这一行并包括这一行的函数所花费的累计时间
    • 第三列显示的是花费在这个函数上的时间
    • 第四列显示的是它被调用的次数(递归调用不计算在内)
  • 剖析报告的第二部分是函数的调用历史

    • 根据这个调用信息,我们通常可以推断出关于程序行为的有用信息

小结

  • 当处理大型程序时,将注意力集中在最耗时的部分变得很重要。代码剖析程序和相关的工具能帮助我们系统地评价和改进程序性能。
  • 我们描述了GPROF,一个标准的Unix剖析工具,还有更加复杂完善的剖析程序可用,例如Intel的VTUNE程序开发系统,还有Linux系统基本上都有的VALGRIND。
  • 这些工具可以在过程级分解执行时间,估计程序每个基本块(basic block)的性能。(基本块是内部没有控制转移的指令序列,因此基本块总是整个被执行的)

第六章 存储器层次结构

  • 存储器系统(memory system)是一个具有不同容量,成本和访问时间的存储设备的层次结构。CPU寄存器保存着最常用的数据。

  • 靠近CPU的小的,快速的高速缓存存储器(cache memroy)作为一部分存储在相对慢速的主存储器(main memory)中的数据和指令的缓冲区域

  • 主存缓存,存储在容量较大的,慢速磁盘上的数据,而这些磁盘常常有作为存储在通过网络连接的其他机器的磁盘或磁带上的数据的缓冲区域

  • 这个思想围绕着计算机程序的一个称为局部性(locality)的基本属性。具有良好局部性的程序倾向于一次又一次地访问相同的数据项集合,或是倾向于访问邻近的数据项集合。

  • 具有良好局部性的程序比局部性差的程序更多地倾向于从存储器层次结构中较高层次出访问数据项,因此运行得更快

随机访问存储器

  • 随机访问存储器(Random-Access Memory, RAM)分为两类:静态的和动态的

  • 静态RAM(SRAM)比动态RAM(DRAM)更快,但也贵得多

  • SRAM用来作为高速缓存存储器,既可以在CPU芯片上,也可以在片下

  • SRAM将每个位存储在一个双稳态的(bistable)存储器单元里。每个单元是用一个六晶体管电路来实现的。这个电路有这样一个属性,它可以无限期地保持在两个不同电压配置(configuration)或状态(state)之一。其他任何状态都是不稳定的:从不稳定状态开始,电路会讯速地转移到两个稳定状态中的其中一个。这样一个存储器单元类似于一个倒转的钟摆

  • DRAM将每个位存储为一个电容的充电

  • 内存模块

    • DRAM芯片封装在内存模块(memory module)中,它查到主板的扩展槽上

非易失性存储器

  • 如果断电,DRAM和SRAM会丢失它们的信息,从这个意义上说,它们是易失的(volatile)。

  • 另一方面,非易失性存储器(nonvolatile memory)即使是在关电后,仍然保存着它们的信息。

  • 由于历史原因,虽然ROM中有的类型既可以读也可以写,但是它们整体上都被称为只读存储器(Read-Only Memory, ROM)。

  • ROM是它们能够被重编程(写)的次数和对它们进行重编程所用的机制来区分的

  • PROM(Programmable ROM, 可编程ROM)只能被编程一次。PROM的每个存储器单元有一种熔丝(fuse),只能用高电流熔断一次

  • 可擦写可编程ROM(Erasable Programmable ROM, EPROM)有一个透明的石英窗口,允许光到达存储单元。紫外线光照射过窗口,EPROM单元就被清除为0。对EPROM编程是通过使用一种把1写入EPROM的特殊设备来完成的。

  • EPROM能够被擦除和重编程的次数的数量级可以达到1000次。

  • 电子可擦除EROM(Electrically Erasable PROM, EEPROM)类似于EPROM,但是他不需要一个物理上独立的编程设备,因此可以直接在印制电路卡上编程。EEPROM能够被编程的次数的数量级可以达到10的五次方

  • 闪存(flash memory)是一类非易失性存储器,基于EEPROM,他已经称为了一种重要的存储技术。

  • 新型的基于闪存的磁盘驱动器,称为固态硬盘(Solid State Disk, SSD),它能提供相对于传统旋转磁盘的一种更快速,更强健和更低能耗的选择

  • 存储在ROM设备中的程序通常被称为固件(firmware)。当一个计算机系统通电以后,他会运行存储在ROM中的固件。一些系统在固件中提供了少量基本的输入和输出函数:例如PC的BIOS(基本输入/输出系统)例程

  • 逻辑磁盘块

    • 正如我们看到的那样,现代磁盘构造复杂,有多个盘面,这些盘面上有不同的记录区。为了对操作系统隐藏这样的复杂性,现在磁盘将它们的构造呈现为一个简单的视图,一个B个扇区大小的逻辑块的序列,编号为0,1,。。。,B-1。磁盘封装中有一个小的硬件/固件设备,称为磁盘控制器,维护着逻辑块号和实际(物理)磁盘扇区之间的映射关系。
  • 当操作系统想要执行一个I/O操作时,例如读一个磁盘扇区的数据到主存,操作系统会发送一个命令到磁盘控制器,让它读某个逻辑块号。

  • 控制器上的固件执行一个快速表查找,将一个逻辑块号翻译成一个(盘面,磁道,扇区)的三元组,这个三元组唯一的标识了对应的物理扇区,控制器上的硬件会解释这个三元组,将读/写头移动到适当的柱面,等待扇区移动到读/写头下,将读/写头感知到的位放到控制器上的一个小缓冲区中,然后将他们复制到主存中

  • 格式化的磁盘容量

    • 磁盘控制器必须对磁盘进行格式化,然后才能在该磁盘上存储数据
    • 格式化包括用标识扇区的信息填写扇区之间的间隙,标识出表面有故障的柱面并且不使用它们,以及在每个区中预留出一组柱面作为备用,如果区中一个或多个柱面在磁盘使用过程中坏掉了,就可以使用这些备用的柱面
    • 因为存在着这些备用的柱面,所以磁盘制造商所说的格式化容量比最大容量要小

连接I/O设备

  • 例如图形卡,监视器,鼠标,键盘和磁盘这样的输入/输出(I/O)设备,都是通过I/O总线,例如Intel的外围设备互连(Peripheral Component Interconnect, PCI)总线连接到CPU和主存的。

  • 系统总线和内存总线是与CPU相关的,与它们不同,诸如PCI的I/O总线设计成与底层CPU无关

  • 虽然I/O总线比系统总线和内存总线慢,但是它可以容纳种类繁多的第三方I/O设备。例如

    • 通用串行总线(Universal Serial Bus, USB)控制器是一个连接到USB总线的设备的中转机构,USB总线是一个广泛使用的标准,连接各种外围I/O设备,包括键盘,鼠标,调制解调器,数码相机,游戏操纵杆,外部磁盘驱动器和固态硬盘。USB 3.0 总线的最大带宽为625MB/s。USB 3.1 总线的最大带宽为1250MB/s
    • 图形卡(或适配器)包含硬件和软件逻辑,它们负责代表CPU在显示器上画像素
    • 主机总线适配器将一个或多个磁盘连接到I/O总线,使用的是一个特别的主机总线接口定义的通信协议。两个最常用的这样的磁盘接口是SCSI和SATA。SCSI主机总线适配器(通常称为SCSI控制器)可以支持多个磁盘驱动器,而SATA适配器与之不同,只能支持一个驱动器

局部性

  • 一个编写良好的计算机程序尝尝具有良好的局部性(locality)。也就是,它们倾向于引用邻近于其他最近引用过的数据项的数据项,或者最近引用过的数据项本身。这种倾向性,被称为局部性原理(principle of locality),是一个持久的概念,对硬件和软件系统的设计和性能都有着极大地影响。

  • 局部性通常有两种不同的形式:时间局部性(temporal locality)和空间局部性(spatial locality)

    • 在一个具有良好时间局部性的程序中,被引用过一次的内存位置很可能在不远的将来再被多次引用
    • 在一个具有良好空间局部性的程序中,如果一个内存位置被引用了一次,那么程序很可能在不远的将来引用附近的一个内存位置。
  • 量化评价程序中局部性的一些简单原则:

    • 重复引用相同变量的程序有良好的时间局部性
    • 对于具有步长为k的引用模式,步长越小,空间局部性越好。具有步长为1的引用模式的程序有很好的空间局部性。在内存中以大步长跳来跳去的程序空间局部性很差
    • 对于取指令来说,循环有好的时间和空间局部性。循环体越小,循环迭代次数越多,局部性越好。

存储器层次结构中的缓存

  • 一般而言,高速缓存(cache, 读作”cash”)是一个小而快速的存储设备,它作为存储在更大,也更慢的设备中的数据对象的缓冲区域。使用高速缓存的过程称为缓存(caching 读作”cashing”)

  • 存储器层次结构的中心思想是:对于每个k,位于k层的更快更小的存储设备作为位于k+1层的更大更慢的存储设备的缓存。换句话说,层次结构中的每一层都缓存来自较低一层的数据对象。例如,本地磁盘作为通过网络从远程磁盘取出的文件的缓存,主存作为本地磁盘上数据的缓存,以此类推,直到最小的缓存:CPU寄存器组

存储器层次结构概念小结

  • 概括来说,基于缓存的存储器层次结构行之有效,是因为较慢的存储设备比较快的存储设备更便宜,还因为程序倾向于展示局部性:
    • 利用时间局部性:由于时间局部性,同一数据对象可能会被多次使用。一旦一个数据对象在第一次不明中时被复制到缓存中,我们就会其往后面对该目标有一系列的访问命中。因为缓存比较低一层的存储设备更快,对后面的命中的服务会比最开始的不命中快很多
    • 利用空间局部性:块通过包括有多个数据对象。由于空间局部性,我们会期望后面对该块中其他对象的访问能够补偿不命中后复制该块的花费。

第七章 链接

  • 链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。

  • 链接可以执行于编译时(compile time),也就是在源代码被翻译成机器代码时;

  • 也可以执行于加载时(load time),也就是在程序被加载器(loader)加载到内存并执行时;

  • 甚至执行于运行时(run time),也就是由应用程序来执行。

  • 在早期的计算机系统中,链接是手动执行的。在现代系统中,链接是由叫做链接器(linker)的程序自动执行的

  • 大多数编译系统提供编译器驱动程序(compiler driver),它代表用户在需要时调用语言预处理器,编译器,汇编器和链接器

  • 链接器的一些基本事实:

    • 目标文件纯粹是字节快的集合。
    • 在这些块中,有些包含程序代码,有些包含程序数据,而其他的则包含引导链接器和加载器的数据结构
    • 链接器将这些块连接起来,确定被连接块的运行时位置,并且修改代码和数据块中的各种位置
    • 链接器对目标机器了解甚少,产生目标文件的编译器和汇编器已经完成了大部分工作

目标文件

  • 目标文件有三种形式:

    • 可重定位目标文件:包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件
    • 可执行目标文件:包含二进制代码和数据,其形式可以被直接复制到内存并执行
    • 共享目标文件:一种特殊类型的可重定位目标文件,可以再加载或者运行时被动态的加载进内存并链接
  • 编译器和汇编器生成可重定位目标文件(包括共享目标文件)。

  • 链接器生成可执行目标文件

  • 从技术上来说,一个目标模块(object module)就是一个字节序列,而一个目标文件(object file)就是一个以文件形式存放在磁盘中的目标模块

  • 目标文件是按照特定的目标文件格式来组织的,各个系统的目标文件格式都不相同。现代x86-64 Linux和Unix系统使用可执行可链接格式(Executable and Linkable Format, ELF)

符号和符号表

  • 每个可重定位目标模块m都有一个符号表,它包含m定义和引用的符号的信息。在链接器的上下文中,有三种不同的符号:
    • 由模块m定义并能被其他模块引用的全局符号。全局链接器符号对应于非静态的C函数和全局变量
    • 由其他模块定义并被模块m引用的全局符号。这些符号称为外部符号,对应于在其他模块中定义的非静态C函数和全局变量
    • 只被模块m定义和引用的局部符号。它们对应于带static属性的C函数和全局变量。这些符号在模块m中任何位置都可见,但是不能被其他模块引用

符号解析

  • 链接器解析符号引用的方法是:将每个引用与它输入的可重定位目标文件的符号表中的一个确定的符号定义关联起来。
  • 对那些和引用定义在相同模块中的局部符号的引用,符号解析是非常简单明了的。编译器只允许每个模块中每个局部符号有一个定义。静态局部变量也会有本地链接器符号,编译器还要确保它们拥有唯一的名字

对C++和Java中链接器符号的重整

  • C++和Java都允许重载方法,这些方法在源代码中有相同的名字,却有不同的参数列表。

  • C++和Java中能使用重载函数,是因为编译器将每个唯一的方法和参数列表组合编码成一个对链接器来说唯一的名字。这种编码过程叫做重整(mangling),而相反的过程叫做恢复(demangling)

  • 幸运的是,C++和Java使用兼容的重整策略。一个被重整的类名字是由名字中字符的整数数量,后面跟原始名字组成的。

GCC -fno-common

  • GCC -fno-common标志这样的选调用链接器,这个选项会告诉链接器,在遇到多重定义的全局符号时,触发一个错误。

  • 或者使用-Werror选项,他会把所有的警告都变为错误

与静态库链接

  • 所有的编译系统都提供一种机制,将所有相关的目标模块打包成为一个单独的文件,称为静态库(static library),它可以用做链接器的输入。

  • 当链接器构造一个输出的可执行文件时,它只复制静态库里被应用程序引用的目标模块

  • 在Linux系统中,静态库以一种称为存档(archive)的特殊文件格式存放在磁盘中,存档文件是一组连起来的可重定位目标文件的集合,有一个头部用来描述每个成员目标文件的大小和位置。存档文件名由后缀.a标识

  • gcc -c main2.c

  • gcc -static -o prog2c main2.o -L. -lvector

    • -static参数告诉编译器驱动程序,链接器应该构建一个完全链接的可执行目标文件,它可以加载到内存并运行,在加载时无需更进一步的链接
    • -lvector参数是libvector.a的缩写,
    • -L.参数高速链接器在当前目录下查找libvector.a

链接器如何使用静态库来解析引用

  • 在符号解析阶段,链接器从左到右按照它们在编译器驱动程序命令行上出现的顺序来扫描可重定位目标文件和存档文件(驱动程序自动将命令行中所有的.c文件翻译为.o文件)

  • 关于库的一般准则是将它们放在命令行的结尾

加载可执行目标文件

  • 任何Linux程序都可以通过调用execve函数来调用加载器

  • 加载器将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序。这个将程序复制到内存并运行的过程叫做加载

  • 每个Linux程序都有一个运行时内存映像。

  • 在Linux x86-64系统中,代码段总是从地址0x4000000处开始,后面是数据段。

  • 运行时堆在数据段之后,通过调用malloc库往上增长。

  • 堆后面的区域是为共享模块保留的。

  • 用户栈总是从最大的合法用户地址开始,向较小内存地址增长。

  • 栈上的区域,从地址2的28次方开始,是为内核中的代码和数据保留的,所谓内核就是操作系统驻留在内存的部分

加载器实际是如何工作的

  • Linux系统中的每个程序都运行在一个进程上下文中,有自己的虚拟地址空间。当shell运行一个程序时,父shell进程生成一个子进程,它是父进程的一个复制。子进程通过execve系统调用启动加载器。加载器删除子进程现有的虚拟内存段,并创建一组新的代码,数据,堆和栈段。新的栈和堆段被初始化为零。通过将虚拟地址空间中的页映射到可执行文件的页大小的片(chunk),新的代码和数据段被初始化为可执行文件的内容。最后,加载器跳转到_start地址,它最终会调用应用程序的main函数。除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据复制。直到CPU引用一个被映射的虚拟页时才会进行复制,此时,操作系统利用它的页面调度机制自动将页面从磁盘传送到内存。

共享库

  • 共享库(shared library),是致力于解决静态库缺陷的一个现代创新产物。共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程称为动态链接(dynamic linking),是由一个叫做动态链接器(dynamic linker)的程序来执行的

  • 共享库也称为共享目标(shared object),在Linux系统中通常用.so后缀来表示。微软的操作系统大量地使用了共享库,它们称为DLL(动态链接库)

  • 共享库的一个主要目的就是允许多个正在运行的进程共享内存中相同的库代码,因而节约宝贵的内存资源

  • 可以加载而无需重定位的代码称为位置无关代码(Position-Independent Code, PIC)。

  • 用户对GCC使用-fpic选项指示GNU编译系统生成PIC代码。共享库的编译必须总是使用该选项

处理目标文件的工具

  • 在Linux系统中有大量可用的工具可以帮助理解和处理目标文件。特别的,GNU binutils包尤其有帮助,而且可以运行在每个Linux平台上

  • AR:创建静态库,插入,删除,列出和提取成员

  • STRINGS:列出一个目标文件中所有可打印的字符串

  • STRIP:从目标文件中删除符号表信息

  • NM:列出一个目标文件的符号表中定义的符号

  • SIZE:列出目标文件中节的名字和大小

  • READELF:显示一个目标文件的完整结构,包括ELF头中编码的所有信息,包含SIZE和NM的功能

  • OBJDUMP:所有二进制工具之母。能够显示一个目标文件中所有的信息。它最大的作用是反汇编.text节中的二进制指令

  • Linux系统为操作共享库还提供了LDD程序

第八章 异常控制流

异常

  • 异常,是异常控制流的一种形式,它一部分由硬件实现,一部分由操作系统实现
  • 异常(exception)就是控制流中的突变,用来相应处理器状态中的某些变化

异常的类别

  • 异常可以分为四类:

    • 中断(interrupt)
      • 原因:来自I/O设备的信号
      • 类型:异步
      • 返回行为:总是返回到下一条指令
    • 陷阱(trap)
      • 原因:有意的异常
      • 类型:同步
      • 返回行为:总是返回到下一条指令
    • 故障(fault)
      • 原因:潜在可恢复的错误
      • 类型:同步
      • 返回行为:可能返回到当前指令
    • 终止(abort)
      • 原因:不可恢复的错误
      • 类型:同步
      • 返回类型:不会返回
  • 中断是异步发生的,是来自处理器外部的I/O设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的。硬件中断的异常处理程序尝尝称为中断处理程序(interrupt handler)

  • 剩下的异常类型(陷阱,故障和终止)是同步发生的,是执行当前指令的结果。我们把这类指令叫做故障指令(faulting instruction)

进程

  • 进程的经典定义就是一个执行中程序的实例

  • 关于操作系统如何实现进程的细节的讨论超出了本书的范围。反之,我们将关注进程提供给应用程序的关键抽象:

    • 一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器
    • 一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统

并发流

  • 计算机系统中国逻辑流有许多不同的形式。异常处理程序,进程,信号处理程序,线程和Java进程都是逻辑流的例子

  • 一个逻辑流的执行在时间上与另一个流重叠,称为并发流(concurrent flow),这两个流被称为并发地运行

  • 多个流并发地执行的一般现象被称为并发(concurrency)。一个进程和其他进程轮流运行的概念称为多任务(multitasking)。一个进程执行它的控制流的一部分的每一个时间段叫做时间片(time slice)。因此,多任务也叫做时间分片(time slicing)

  • 注意,并发流的思想与流运行的处理器核数或者计算机数无关。如果两个流在时间上重叠,那么它们就是并发的,即使它们是运行在同一个处理器上。

  • 不过,有时我们会发现确认并行流是很有帮助的,它是并发流的一个真子集。如果两个流并发地运行在不同的处理器核或者计算机上,那么我们称它们为并行流(parallel flow),它们并行地运行(running in parallel),且并行地执行(parallel execution)

用户模式和内核模式

  • 为了使操作系统内核提供一个无懈可击的进程抽象,处理器必须提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址范围

  • 处理器通常是用某个控制寄存器中的一个模式位(mode bit)来提供这种功能,该寄存器描述了进程当前享有的特权。

  • 当设置了模式位时,进程就运行在内核模式中。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何位置

  • 运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过诸如中断,故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。

  • Linux提供了一种聪明的机制,叫做/proc文件系统,它允许用户模式进程访问内核数据结构的内容。/proc文件系统将许多内核数据结构的内容输出位一个用户程序可以读的文本文件的层次结构。

  • 例如:

    • 可以使用/proc文件系统找出一般的系统属性,例如CPU类型(/proc/cpuinfo)
    • 某个特殊的进程使用的内存段(/proc/ /maps)

创建和终止进程

  • 从程序员的角度,我们可以认为进程总是处于下面三种状态之一:

    • 运行。进程要么在CPU上执行,要么在等待被执行且最终会被内核调度
    • 停止。进程的执行被挂起(supspended),且不会被调度。当收到SIGSTOP, SIGTSTP,SIGTTIN或者SIGTTOU时,进程就停止,并且保持停止直到它收到一个SIGCONT信号,在这个时刻,进程再次开始运行。
    • 终止。进程永远地停止了。进程会因为三种原因终止:
      • 收到一个信号,该信号默认行为是终止进程
      • 从主程序返回
      • 调用exit函数
  • fork函数,被调用一次,返回两次:

    • 一次是在调用进程(父进程)中,一次是在新创建的子进程中。
    • 在父进程中,fork返回子进程的PID。在子进程中,fork返回0.

回收子进程

  • 当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终止的状态中,知道被它的父进程回收(reaped)。当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已经终止的进程,从此时开始,该进程就不存在了。

  • 一个终止了但是还未被回收的进程称为僵死进程(zombie)

  • 如果一个父进程终止了,内核会安排init进程称为它的孤儿进程的养父。init进程的PID为1,是在系统启动时由内核创建的,它不会终止,是所有进程的祖先。如果父进程没有回收它的僵死进程就终止了,那么内核会安排init进程去回收它们。不过长时间运行的程序,例如shell或者服务器,总是应该回收它们的僵死子进程。即使僵死子进程没有运行,它们仍然消耗系统的内存资源

  • pause函数

    • 该函数让调用函数休眠,直到该进程收到一个信号

加载并运行程序

  • execve函数在当前进程的上下文中加载并运行一个新程序

  • 原型:

    1
    2
    3
    #include <unistd.h>

    int execve(const char *filename, const char *argv[], const char *envp[]);
  • execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。

  • 只有当出现错误时,例如找不到filename,execve才会返回到调用程序,所以,与fork一次调用返回两次不同,execve调用一次并从不返回

程序与进程

  • 确认一下所理解的程序和进程之间的区别。

  • 程序是一堆代码和数据;程序可以作为目标文件存在于磁盘上,或者作为段存在于地址空间中。

  • 进程是执行中程序的一个具体的实例;程序总是运行在某个进程的上下文中。

  • 如果想要理解fork和execve函数,理解这个差异是很重要的。

    • fork函数在新的子进程中运行相同的程序,新的子进程是父进程的一个复制品。
    • execve函数在当前进程的上下文中加载并运行一个新的程序。他会覆盖当前进程的地址空间,但并没有创建一个新的进程。新的程序仍然有相同的PID,并且继承了调用execve函数时已打开的所有文件描述符

信号

  • Linux信号,它允许进程和内核中断其他进程

  • 一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的时间。

  • 转储内存(dumping core)是一个历史术语,意思是把代码和数据内存段的映像写到磁盘上

signal函数

  • 原型:

    1
    2
    3
    4
    #include <signal.h>
    typedef void (*sighandler_t)(int);

    sighandler_t signal(int signum, sighandler_t handler);
  • signal函数可以通过下列三种方法之一来改变和信号signum相关联的行为:

    • 如果handler是SIG_IGN,那么忽略类型为signum的信号
    • 如果handler是SIG_DFL,那么类型为signum的信号行为恢复默认行为
    • 否则,handler就是用户定义的函数的地址,这个函数被称为信号处理程序,只要程序接收到一个类型为signum的信号,就会调用这个程序。通过把处理程序的地址传递到signal函数从而改变默认行为,这叫做设置信号处理程序(installing the handler)。调用信号处理程序被称为捕获信号。执行信号处理程序被称为处理信号
  • 不可以用信号来对其他进程中发生的事件计数

C++和Java中的软件异常

  • C++和Java提供的异常机制是较高层次的,是C语言的setjmp和longjmp函数的更加结构化的版本。你可以把try语句中的catch字句看做类似于setjmp函数。相似的,throw语句就类似于longjmp函数

操作进程的工具

  • Linux程序提供了大量的监控和操作进程的有用工具

  • STRACE:

    • 打印一个正在运行的程序和它的子进程调用的每个系统调用的轨迹。
    • 用-static编译你的程序,能够得到一个更加干净的,不带有大量与共享库有关的输出的轨迹
  • PS:

    • 列出当前系统中的进程,包括僵死进程
  • TOP:

    • 打印出关于当前进程资源使用的信息
  • PMAP:

    • 显示进程的内存映射
  • /proc:

    • 一个虚拟文件系统,以ASCII文本格式输出大量内核数据结构的内容,用户程序可以读取这些内容。例如,输出”cat /proc/loadavg”,可以看到你的Linux系统上当前的平均负载

小结

  • 异常控制流(ECF)发生在计算机系统的各个层次,是计算机系统中提供并发的基本机制

  • 在硬件层,异常是由处理器中的事件触发的控制流中的突变。控制流传递给一个软件处理程序,该处理程序进行一些处理,然后返回控制给被中断的控制流

  • 有四种不同类型的异常:中断,故障,终止和陷阱。

  • 当一个外部I/O设备(例如定时器芯片或者磁盘控制器)设置了处理器芯片上的中断管脚时,(对于任意指令)中断会异步地发生。控制返回到故障指令后面的那条指令。一条指令的执行可能导致故障和终止同步发生。故障处理程序会重新启动故障指令,而终止处理程序从不将控制返回给被中断的流。最后,陷阱就像是用来实现向应用提供到操作系统代码的受控的入口点的系统调用的函数调用

  • 在操作系统层,内核用ECP提供进程的基本概念。进程提供给应用两个重要的抽象:

    • 逻辑控制流,它提供给每个进程一个假象,好像它是独占地使用处理器
    • 私有地址空间,它提供给每个程序一个假象,好像它是在独占地使用主存
  • 在操作系统和应用程序之间的接口处,应用程序可以创建子进程,等待它们的子进程停止或者终止,运行新的程序,以及捕获来自其他进程的信号。信号处理的语义是微妙的,并且随系统不同而不同,然后,在与Posix兼容的系统上存在着一些机制,允许程序清楚地指定期望的信号处理语义

  • 最后,在应用层,C程序可以使用非本地跳转来规避正常的调用/返回栈规则,并且直接从一个函数分支到另一个函数。

第九章 虚拟内存

  • 为了更加有效地管理内存并且少出错,现代系统提供了一种对主存的抽象概念,叫做虚拟内存(VM)。

  • 虚拟内存是硬件异常,硬件地址翻译,主存,磁盘文件和内核软件的完美交互,它为每个进程提供了一个大的,一致的和私有的地址空间。

  • 通过一个很清晰的机制,虚拟内存提供了三个重要的能力:

    • 它将主存看成是一个存储在磁盘上的地址空间的高速缓存,它高效地使用了主存
    • 它为每个进程提供了一致的地址空间,从而简化了内存管理
    • 它保护了每个进程的地址空间不被其他进程破坏

物理和虚拟寻址

  • 计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组。每字节都有一个唯一的物理地址(Physical Address, PA)。

  • 第一个字节的地址为0,接下来的字节地址为1,在下一个为2,以此类推。给定这种简单的结构,CPU访问内存的最自然的方式就是使用物理地址。我们把这种方式称为物理寻址(physical addressing)。

  • 早期的PC使用物理寻址,而且诸如数字信号处理器,嵌入式微控制器以及Cray超级计算机这样的系统仍然继续使用这种寻址方式。

  • 使用虚拟寻址,CPU通过生成一个虚拟地址(Virtual Address, VA)来访问主存,这个虚拟地址在被送到内存之前先转换成适当的物理地址。

  • 将一个虚拟地址转换为物理地址的任务叫做地址翻译(address translation)。

  • 就像异常处理一样,地址翻译需要CPU硬件和操作系统之间的紧密合作。CPU芯片上叫做内存管理单元(Memory Management Unit, MMU)的专用硬件,利用存放在主存中的查询表来动态翻译虚拟地址,该表的内容由操作系统管理。

地址空间

  • 地址空间(address space)是一个非负整数地址的有序集合

  • 如果地址空间中的整数是连续的,那么我们说它是一个线性地址空间(linear address space)

  • 在一个带虚拟内存的系统中,CPU从一个有N=2的n次方个地址的地址空间中生成虚拟地址,这个地址空间称为虚拟地址空间(virtual address space)

  • 一个地址空间的大小是由表示最大地址所需要的位数来描述的。例如,一个包含N=2的n次方个地址的虚拟地址空间就叫做一个n位地址空间。现代系统通常支持32位或者64位虚拟地址空间

  • 一个系统还有一个物理地址空间(physical address space),对应于系统中物理内存的M个字节

  • 地址空间的概念是很重要的,因为它清楚的区分了数据对象(字节)和它们的属性(地址)。一旦认识到了这种区别,那么我们就可以将其推广,允许每个数据对象有多个独立的地址,其中每个地址都选自一个不同的地址空间。这就是虚拟内存的基本思想。主存中的每字节都有一个选自虚拟地址空间的虚拟地址和一个选择物理地址空间的物理地址

虚拟内存作为缓存的工具

  • 概念上而言,虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。每字节都有一个唯一的虚拟地址,作为到数组的索引。磁盘上数组的内容被缓存在主存中。

  • 和存储器层次结构中其他缓存一样,磁盘(较低层)上的数据被分割成块,这些块作为磁盘和主存(较高层)之间的传输单元。VM系统通过将虚拟内存分割为称为虚拟页(Virtual Page, VP)的大小固定的块来处理这个问题。类似的,物理内存被分割为物理页(Physical Page, PP)物理页也被称为页帧(page frame)

  • 在任意时刻,虚拟页面的集合都分为三个不相交的子集:

    • 未分配的:VM系统还未分配(或者创建)的页。未分配的块没有任何数据和它们相关联,因此也就不占用任何磁盘空间
    • 缓存的:当前已缓存在物理内存中的已分配页
    • 未缓存的:未缓存在物理内存中的已分配页

DRAM缓存的组织结构

  • 为了有助于清晰理解存储器层次结构中不同的缓存概念,

    • 我们将使用术语SRAM缓存来表示位于CPU和主存之间的L1,L2和L3高速缓存
    • 并且用术语DRAM缓存在表示虚拟内存系统的缓存,它在主存中缓存虚拟页
  • 虚拟内存是在20世纪60年代早期发明的,远在CPU-内存之间差距的加大引发产生的SRAM缓存之前。因此,虚拟内存系统使用了和SRAM缓存不同的术语,即使它们的许多概念是相似的。

  • 在虚拟内存的习惯说法中,块被称为页。在磁盘和内存之间传送页的活动叫做交换(swapping)或者页面调度(paging)。页从磁盘换入(或者页面调入)DRAM和DRAM换出(或者页面调出)磁盘。一直等待,直到最后时刻,也就是当有不命中发生时,才换入页面的这种策略称为按需页面调度(demand paging)

  • 可以利用Linux的getrusage函数检测缺页的数量,以及其他信息

虚拟内存作为内存管理的工具

  • 到目前为止,我们都假设有一个单独的页表,将一个虚拟地址空间映射到物理地址空间。实际上操作系统为每个进程提供了一个独立的页表,因而也就是一个独立的虚拟地址空间。

  • 注意,多个虚拟页面可以映射到同一个共享物理页面上

  • 按需页面调度和独立的虚拟地址空间的结合,对系统中内存的使用和管理造成了深渊的映像。特别的,VM简化了链接和加载,代码和数据共享,以及应用程序的内存分配

  • 简化链接:

    • 独立的地址空间允许每个进程的内存映像使用相同的基本格式,而不管代码和数据实际存放在物理内存的何处
    • 对于64位地址空间,代码段总是从虚拟地址0x400000开始。数据段跟在代码段之后,中间有一段符合要求的对其空白。栈占据用户进程地址空间最高的部分,并向下生长。这样的一致性极大地简化了链接器的设计和实现,允许链接器生成完全链接的可执行文件,这些可执行文件是独立于物理内存中代码和数据的最终位置的。
  • 简化加载:

    • 虚拟内存还使得容易向内存中加载可执行文件和共享对象文件。要把目标文件中.text和.data节加载到一个新创建的进程中,Linux加载器为代码和数据段分配虚拟页,把它们标记为无效的(即未被缓存的),将页表条目指向目标文件中适当的位置。
    • 有趣的是,加载器从不从磁盘到内存实际复制任何数据。在每个页初次被引用时,要么是CPU取指令时引用的,要么是一条正在执行的指令引用一个内存位置时应用的,虚拟内存系统会按照自动地调入数据也。
  • 将一组连续的虚拟页映射到任意一个文件中的任意位置的表示法称作内存映射(memory mapping)。Linux提供一个称为mmap的系统调用,允许应用程序自己做内存映射

  • 简化共享:

    • 独立地址空间为操作系统提供了一个管理用户进程和操作系统自身之间共享的一致机制。
    • 一般而言,每个进程都有自己私有的代码,数据,堆以及栈区域,是不和其他进程共享的。在这种情况中,操作系统创建页表,将相应的虚拟页映射到不连续的物理页面

虚拟内存作为内存保护的工具

  • 任何现代计算机系统必须为操作系统提供手段来控制对内存系统的访问。

    • 不应该允许一个用户进程修改它的只读代码段
    • 不应该允许它读或修改任何内核中的代码和数据结构
    • 不应该允许它读或者写其他进程的私有内存
    • 不允许它修改任何与其他进程共享的虚拟页面,除非所有的共享者都显示的允许它这么做(通过调用明确的进程间通信系统调用)
  • 如果一条指令违反了这些许可条件,那么CPU就触发一个一般保护故障,将控制传递给一个内核中的异常处理程序。Linux shell一般将这种异常报告为 段错误(segmentation fault)

内存映射

  • Linux通过将一个虚拟内存区域与一个磁盘上的对象(object)关联起来,以初始化这个虚拟内存区域的内容,这个过程称为内存映射(memory mapping)

  • 虚拟内存区域可以映射到两种类型的对象中的一种:

    • Linux文件系统中的普通文件:一个区域可以映射到一个普通磁盘文件的连续部分
    • 匿名文件:一个区域也可以映射到一个匿名文件,匿名文件是由内核创建的,包含的全是二进制零
  • 无论在哪种情况下,一旦一个虚拟页面被初始化了,它就在一个由内核维护的专门的交换文件(swap file)之间换来换去。交换文件也叫做交换空间(swap space)或者交换区域(swap area)

动态内存分配

  • 虽然可以使用低级的mmap和munmap函数来创建和删除虚拟内存的区域,但是C程序员还是会觉得当运行时需要额外虚拟内存时,用动态内存分配器(dynamic memory allocator)更方便,也有更好的可移植性

  • 动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap)。

  • 假设堆是一个请求二进制零的区域,他紧接着再未初始化的数据区域后开始,并向上生长(向更高的地址)。

  • 对于每个进程,内核维护着一个变量brk(读做 break),他指向堆的顶部

  • 分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已经分配的,要么是空闲的。

  • 已分配的块显示地保留为供应用程序使用。空闲块可用来分配。

  • 空闲块保持空闲,直到他显示的被应用所分配。

  • 一个已经分配的块保持已经分配状态,直到它被释放,这种释放要么是应用程序显示执行的,要么是内存分配器自身隐式执行的

  • 分配器有两种基本风格。两种风格都要求应用显示地分配块。他们的不同之处在于由那个实体来负责释放已经配分的块。

    • 显示分配器(explicit allocator),要求应用显示的释放任何已经分配的块。例如,C标准库提供一种叫做malloc程序包的显示分配器。
      • C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块
      • C++中的new和delete操作符与C中的malloc和free相当
  • 隐式分配器(implicit allocator),另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。

    • 隐式分配器也叫做垃圾收集器(garbage collector),而自动释放未使用的已分配的块的过程叫做垃圾收集(garbag collection)。
    • 例如,诸如Lisp, ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块

为什么要使用动态内存分配

  • 程序使用动态内存分配的最重要的原因是经常直到程序实际运行时,才知道某些数据结构的大小。

分配器的要求和目标

  • 显式分配器必须再一些相当严格的约束条件下工作:
    • 处理任意请求序列。一个应用可以有任意的分配请求和释放请求序列,只要满足约束条件:每个释放请求必须对应于一个当前已分配块,这个块是由一个以前的分配请求获得的。因此,分配器不可以假设分配和释放请求的顺序。例如,分配器不能假设所有的分配请求都有想匹配的释放请求,或者有相匹配的分配和空闲请求是嵌套的
    • 立即响应请求。分配器必须立即响应分配请求。因此,不允许分配器为了提高性能重新排列或者缓冲请求
    • 只使用堆。为了使分配器是可扩展的,分配器使用的任何非标量数据结构都必须保存在堆里
    • 对齐块(对齐要求)。分配器必须对齐块,使得它可以保存任何类型的数据对象
    • 不修改已分配的块。分配器只能操作或者改变空闲块。特别是,一旦块被分配了,就不允许修改或者移动它了。因此,诸如压缩已分配块这样的技术是不允许使用的。

C程序中常见的与内存有关的错误

  • 间接引用坏指针

    • 再进程的虚拟地址空间中有较大的洞,没有映射到任何有意义的数据。如果我们试图间接引用一个指向这些洞的指针,那么操作系统就会以段异常中止程序。
    • 而且,虚拟内存的某些区域是只读的。试图写这些区域将会以保护异常中止这个程序。
  • 读未初始化的内存

    • 虽然bss内存位置(诸如未初始化的全局C变量)总是被加载器初始化为零,但是对于堆内存却不是这样的。
    • 一个常见的错误就是假设堆内存被初始化为零
  • 允许栈缓冲区溢出

    • 如果一个程序不检查输入串的大小就写入栈中的目标缓冲区,那么这个程序就会有缓冲区溢出错误(buffer overflow bug)
  • 假设指针和它们指向的对象是相同大小的

    • 一种常见的错误是假设指向对象的指针和他们所指向的对象是相同大小的。
  • 造成错位错误

    • 错位(off-by-one)错误是另一种很常见的造成覆盖错误的来源
  • 引用指针,而不是它所指向的对象

    • 如果不太注意C操作符的优先级和结合性,我们就会错误地操作指针,而不是指针所指向的对象。
  • 误解指针运算

    • 另一种常见的错误是忘记了指针的算术操作是以他们指向的对象的大小为单位阿里进行的,而这种大小单位并不一定是字节
  • 引用不存在的变量

    • 没有太多经验的C程序员不理解栈的规则,有时会引用不合法的本地变量
  • 引用空闲堆块中的数据

    • 一个相似的错误是引用已经被释放了的堆块中的数据。
  • 引起内存泄漏

    • 内存泄漏是缓慢,隐性的杀手,当程序员不小心忘记释放已分配块,而再堆里创建了垃圾时,会发生这种问题

小结

  • 虚拟内存是对主存的一个抽象。支持虚拟内存的处理器通过使用一种叫做虚拟寻址的间接形式来引用主存。处理器产生一个虚拟地址,在被发送到主存之前,这个地址被翻译成一个物理地址。从虚拟地址空间到物理地址空间的地址翻译要求硬件和软件紧密合作。专门的硬件通过使用页表来翻译虚拟地址,而页表的内容是由操作系统提供的。

第十章 系统级I/O

  • 输入/输出(I/O)是在主存和外部设备(例如磁盘驱动器,终端和网络)之间复制数据的过程。输入操作是从I/O设备复制数据到主存,而输入操作是从主存复制数据到I/O设备。

Unix I/O

  • 一个Linux文件就是一个m个字节的序列
  • 所有的I/O设备(例如网络,磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单,低级的应用接口,称为Unix I/O。这使得所有的输入和输出都能以一种统一且一致的方式来执行:
    • 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需要记住这个描述符。
    • Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0),标准输出(描述符为1)和标准错误(描述符为2).头文件<unistd.h>定义了常量STDIN_FILENO, STDOUT_FILENO与STDERR_FILENO,他们可用来代替显示的描述符常量
    • 改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显示地设置文件的当前位置。
    • 读写文件。一个读操作就是从文件复制n > 0个字节到内存,从当前文件位置k开始,然后将k增加k+n。给定一个大小为m字节的文件,当k >= m时执行读操作会触发一个称为end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的EOF符号
    • 关闭文件。当应用完成了对文件的访问之后,就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因中止时,内核都会关闭所有打开的文件并释放他们的内存资源。

文件

  • 每个Linux文件都有一个类型(type)来表明它在系统中的角色:

  • 普通文件(regular file)包含任意数据。应用程序常常要区分文本文件(text file)和二进制文件(binary file)。

    • 文本文件是只包含有ASCII或Unicode字符的普通文件
    • 二进制文件是所有其他的文件。
    • 对内核而言,文本文件和二进制文件没有区别
    • Linux文本文件包含了一个文本行(text line)序列,其中每一行都是一个字符序列,以一个新行符(“\n”)结束。新行符与ASCII的换行符(LF)是一样的,其数字值为0x0a
  • 目录(directory)是包含一组链接(link)的文件,其中每个链接都将一个文件名(filename)映射到一个文件,这个文件可能是另一个目录。每个目录至少包含有两个条目

    • .是到该目录自身的链接
    • ..是到目录层次结构中父目录(parent directory)的链接
  • 套接字(socket)是用来与另一个进程进行跨网络通信的文件

小结

  • Linux提供了少量的基于Unix I/O模型的系统级函数,它们允许应用程序打开,关闭,读和写文件,提取文件的元数据,以及执行I/O重定向。Linux的读和写操作会出现不足值,应用程序必须能正确地预计和处理这种情况。应用程序不应直接调用Unix I/O函数

  • Linux内核使用三个相关的数据结构来表示打开的文件。描述符表中的表项指向打开文件表中的表项,而打开文件表中的表项又指向v-node表中的表项。每个进程都有它自己单独的描述符表,而所有进程共享同一个打开文件表和v-node表。理解这些结构的一般组成就能使我们清楚的理解文件共享和I/O重定向

  • 标准I/O库是基于Unix I/O实现的,并提供了一组强大的高级I/O例程。对于大多数应用程序而言,标准I/O更简单,是优于Unix I/O的选择。然而,因为对标准I/O和网络文件的一些相互不兼容的限制,Unix I/O比之标准I/O更适用于网络应用程序

第十一章 网络编程

  • 对主机而言,网络只是又一种I/O设备,是数据源和数据接收方。

  • 一个插到I/O总线扩展槽的适配器提供了到网络的物理接口。从网络上接收到的数据从适配器经过I/O和内存总线赋值到内存,通常是tongguoDMA传送。相似的,数据也能从内存复制到网络

  • 从Linux内核的角度来看,一个套接字就是通信的一个端点。从Linux程序的角度来看,套接字就是一个有相应描述符的打开文件

Web 内容

  • 对于Web客户端和服务器而言,内容是与一个MIME(Multipurpose Internet Mail Extensions, 多用途的网际邮件扩充协议)类型相关的字节序列。

  • 常用的MIME类型:

    • text/html – HTML页面
    • text/plain – 无格式文本
    • application/postscript – Postscript文档
    • image/gif – GIF格式编码的二进制图像
    • image/png – PNG格式编码的二进制图像
    • image/jpeg – JPEG格式编码的二进制图像
  • Web服务器以两种不同的方式向客户端提供内容:

    • 取一个磁盘文件,并将它的内容返回给客户端。磁盘文件称为静态内容(static content),而返回文件给客户端的过程称为服务静态内容(serving static content)
    • 运行一个可执行文件,并将它的输出返回给客户端。运行时可执行文件产出的输出称为动态内容(dynamic content),而运行程序并返回它的输出到客户端的过程称为服务动态内容(serving dynamic content)
  • 状态码(status-code)是一个三位的正整数,指明对请求的处理,状态消息(status message)给出与错误代码等价的英文描述

  • 常见的状态码,以及它们相应的消息:

    • 200 – 成功 – 处理请求无误
    • 301 – 永久移动 – 内容已移动到location头中指明的主机上
    • 400 – 错误请求 – 服务器不能理解请求
    • 403 – 禁止 服务器无权访问所请求的文件
    • 404 – 未发现 服务器不能找到所请求的文件
    • 501 – 未实现 服务器不支持请求的方法
    • 505 – HTTP版本不支持 服务器不支持请求的版本

小结

  • 每个网络应用都是基于客户端-服务器模型的。根据这个模型,一个应用是由一个服务器和一个或多个客户端组成的。服务器管理资源,以某种方式操作资源,为它的客户端提供服务。客户端-服务器模型中的基本操作是客户端-服务器事务,它是由客户端请求和跟随其后的服务器响应组成的。

  • 客户端和服务器通过因特网这个全球网络来通信。从程序员的观点来看,我们可以把因特网看成是一个全球范围的主机集合,具有一下几个属性:

    • 每个因特网主机都有一个唯一的32位名字,称为它的IP地址
    • IP地址的集合被映射为一个因特网域名的集合
    • 不同因特网主机上的进程能够通过连接互相通信
  • 客户端和服务器通过使用套接字建立连接。一个套接字是连接的一个端点,连接以文件描述符的形式提供给应用程序。套接字接口提供了打开和关闭套接字描述符的函数。客户端和服务器通过读写这些描述符来实现彼此间的通信

第十二章 并发编程

  • 使用应用级并发的应用程序称为并发程序(concurrent program)。现代操作系统提供了三种基本的构造并发程序的方法:
    • 进程。用这种方法,每个逻辑控制流都是一个进程,由内核来调度和维护。因为进程有独立的虚拟地址空间,想要和其他流通信,控制流必须使用某种显式的进程间通信(interprocess communication, IPC)机制
    • I/O多路复用。在这种形式的并发编程中,应用程序在一个进程的上下文中显式地调度它们自己的逻辑流。逻辑流被模型化为状态机,数据到达文件描述符后,主程序显式的从一个状态转换到另一个状态。因为程序是一个单独的进程,所以所有的流都共享同一个地址空间。
    • 线程。线程是运行在一个单一进程上下文中的逻辑流,由内核进行调度。你可以把线程看成是其他两种方式的混合体,像进程流一样由内核进行调度,而像I/O多路复用流一样共享同一个虚拟地址空间

基于线程的并发编程

  • 到目前为止,我们已经看到了两种创建并发逻辑流的方法。

    • 在第一种方法中,我们为每个流使用了单独的进程。内核会自动调度每个进程,而每个进程都有它自己的私有地址空间,者使得流共享数据很困难
    • 在第二种方法中,我们创建自己的逻辑流,并利用I/O多路复用来显式的调度流。因为只有一个进程,所有的流共享整个地址空间
  • 线程(thread)就是运行在进程上下文中的逻辑流。线程由内核自动调度。每个线程都有它自己的线程上下文(thread context),包括一个唯一的整数线程ID(Thread ID, TID),栈,栈指针,程序计数器,通用目的寄存器和条件码。所有的运行在一个进程里的线程共享该进程的整个虚拟地址空间

分离线程

  • 在任何一个时间点上,线程是可结合的(joinable)或者是分离的(detached)。一个可结合的线程能够被其他线程收回和杀死。在被其他线程回收之前,它的内存资源(例如栈)是不释放的。相反,一个分离的线程是不能被其他线程回收或杀死的。它的内存资源在它终止时由系统自动释放

线程安全

  • 当用线程编写程序时,必须小心地编写那些具有称为线程安全性(thread safety)属性的函数。一个函数被称为线程安全的(thread-safe),当且仅当被多个并发线程反复调用时,它会一直产生正确的结果。如果一个线程不是线程安全的,我们就说它是线程不安全的(thread-unsafe)

  • 我们能够定义出四个(不相交的)线程不安全函数类:

    • 不保护共享变量的函数
    • 保护跨越多个调用的状态的函数
    • 返回指向静态变量的指针的函数
    • 调用线程不安全函数的函数

可重入性

  • 有一类重要的线程安全函数,叫做可重入函数(reentrant function),其特点在于它们具有这种一种属性:
    • 当它们被多个线程调用时,不会引用任何共享数据。

竞争

  • 当一个程序的正确性依赖于一个线程要在另一个线程到达y点之前到达它的控制流中的x点时,就会发生竞争(race)

  • 通常发生竞争是因为程序员假定线程将按照某种特殊的轨迹线穿过执行状态空间,而忘记了另一条准则规定:多线程的程序必须对任何可行的轨迹线都正确工作

死锁

  • 死锁,指的是一组线程被阻塞了,等待一个永远也不会为真的条件

  • 互斥锁加锁顺序规则:

    • 给定所有互斥操作的一个全序,如果每个线程都是以一种顺序获得互斥锁并以相反的顺序释放的,那么这个程序就是无死锁的

附录 错误处理

  • 系统级函数调用使用三种不同风格的返回错误:
    • Unix风格
    • Posix风格
    • GAI风格

Unix风格的错误处理

  • 像fork和wait这样Unix早期开发出来的函数返回值即包括错误代码,也包括有用的结果

Posix风格的错误处理

  • 许多较新的Posix函数,例如Pthread函数,只用返回值来表明成功(0)或者失败(非0)。任何有用的结果都返回在通过引用传递进来的函数参数中。我们称这种方法为Posix风格的错误处理

GAI风格的错误处理

  • getaddrinfo(GAI)和getnameinfo函数成功时返回零,失败是返回非零值

简介

  • 机器人学导论 第三版
    • 机器人学研究的是怎样综合运用机械,传感器,驱动器和计算机来实现人类某些方面的功能。
    • 在更高的层次上,可以把机器人学划分为四个主要领域:机械操作,移动,计算机视觉和人工智能

绪论

  • 一般来说,对于操作臂的机构和控制理论的研究并不是一门新的学科,它只不过是对传统学科理论的一种综合。
    • 机械工程理论为研究静态和动态环境下的操作臂提供了方法论;
    • 数学方法用于描述机械手空间运动及其特性
    • 控制理论为实现期望运动或力提供了各种设计方法和评估算法;
    • 电气工程技术可用于传感器及工业机器人接口的设计
    • 计算机技术提供了执行期望任务所需的编程平台

位姿描述

  • 在机器人研究中,我们通常在三维空间中研究物体的位置。通常这些物理可用两个非常重要的特性来描述:位置和姿态

  • 为了描述空间物体的位姿,我们一般先将物体固置于一个空间坐标系,即参考系中,然后我们就在这个参考坐标系中研究空间物体的位置和姿态

  • 运动学研究物体的运动,而不考虑引起这种运动的力。在运动学中,我们研究位置,速度,加速度和位置变量对于时间或者其他变量的高阶微分。这样操作臂运动学的研究对象就是运动的全部几何和时间特性

操作臂正运动学

  • 几乎所有的操作臂都是由刚性连杆组成的,相邻连杆间由可作相对运动的关节连接。这些关节通常装有位置传感器,用来测量相邻连杆间的相对位置。
  • 如果是转动关节,这个位移被称为关节角。
  • 一些操作臂含有滑动(或移动)关节,那么两个相邻连杆的位移是直线运动,有时将这个位移称为关节偏距

  • 操作臂自由度的数目是操作臂中具有独立位置变量的数目,这些位置变量确定了机构中所有部件的位置

  • 对于一个典型的工业机器人来讲,由于操作臂大都是开式的运动链,而且每个关节位置都由一个独立的变量定义,因此关节数目等于自由度数目

  • 末端执行器安全在操作臂的自由端。

  • 我们通常用附着于末端执行器上的工具坐标系描述操作臂的位置,与工具坐标系相对应的是与操作臂固定底座相连的基坐标系


  • 在操作臂运动学的研究中一个典型的问题是操作臂正运动学。计算操作臂末端执行器的位置和姿态是一个静态的几何问题。

  • 正运动学方程描述了各个关节变量在工具坐标系与基坐标系间的函数关系

  • 具体来讲,给定一组关节角的值,正运动学问题是计算工具坐标系相对于基坐标系的位置和姿态。一般情况下,我们将这个过程称为从关节空间描述到笛卡尔空间描述的操作臂位置表示。(第三章)

  • 在笛卡尔坐标系中,我们用三个变量来描述空间一点的位置,而用另外三个变量描述物体的姿态。有时将此称为任务空间或者操作空间

操作臂逆运动学

  • 在第四章中,我们将讨论操作臂逆运动学。这个问题就是给定操作臂末端执行器的位置和姿态,计算所有可达给定位置和姿态的关节角。这是操作臂实际应用中的一个基本问题。

  • 给定工具坐标系的位置和姿态,通过逆运动学可以计算各关节变量

  • 从某种程度上讲,逆运动学问题的求解对于操作臂系统来说是最重要的部分。

  • 我们认为这是个“定位”映射问题,是将机器人位姿从三维笛卡尔空间向内部关节空间的映射。

  • 当机器人目标位置用外部三维空间坐标表示时,则需要进行这种映射

  • 运动学方程解的存在与否限定了操作臂的工作空间。无解表示目标点处在工作空间之外,因此操作臂不能达到这个期望位姿

速度 静力 奇异性

  • 除了分析静态定位问题之外,我们还希望分析运动中的操作臂。为操作臂定义雅可比矩阵可以比较方便地进行机构的速度分析。

  • 雅可比矩阵定义了从关节空间速度向笛卡尔空间速度的映射。这种映射关系随着操作臂位形的变化而变化。

  • 关节速率和末端执行器速率的几何关系可以通过雅可比矩阵表示

  • 在奇异点雅可比矩阵是不可逆的。对这种现象的正确理解对于操作臂的设计者和用户都是十分重要的。

  • 机构奇异性:所有的机械装置都会有这种问题,包括机器人。正如后座舱机枪一样,这些奇异性并不影响机器人手臂在其工作空间内的定位,然而,机械臂在这些奇异点附近运动时会出现一些问题。

动力学

  • 动力学是一个广泛的研究领域,主要研究产生运动所需要的力。为了使操作臂从静止开始加速,使末端执行器以恒定的速度作直线运动,最后减速停止,必须通过关节驱动器产生一组复杂的力矩函数来实现。

    • 我们用关节驱动器作为操作臂驱动装置的通用术语,它可以是电机,气缸,液压缸等
  • 关节驱动器产生的力矩函数形式取决于末端执行器路径的空间形式和瞬时特性,连杆和负载的质量特性以及关节摩擦等因素。

  • 控制操作臂沿期望路径运动的一种方法是,通过运用操作臂动力学方程求解出这些关节力矩函数。

轨迹生成

  • 平稳控制操作臂从一点运动到另一点,通常的方法是使每个关节按照指定的时间连续函数来运动。

  • 一般情况下,操作臂各关节同时开始或者同时停止运动,这样操作臂的运动才显得协调。轨迹生成就是如何准确计算出这些运动函数。

  • 通常,一条路径的描述不仅需要确定期望目标,而且还需要确定一些中间点或者路径点,操作臂必须通过这条路到达目标。有时用术语样条函数来表示通过一系列路径点的连续函数。

  • 为了使末端执行器在空间中走出一条直线(或者其他的几何形状),那么必须将末端执行器的期望运动转化成一系列等效的关节运动,这种笛卡尔轨迹生成将在第七章讨论

第二章 空间描述和变换

概述

  • 机器人操作的定义是指通过某种机构使零件和工具在空间运动。这自然就需要表达零件,工具以及机构本身的位置和姿态。为了定义和运用表达位姿的数学量,我们必须定义坐标系并给出表达的规则。
  • 我们采用这样一个体系,即存在着一个世界坐标系,我们讨论任何问题都能够参照这个坐标系。我们定义的位姿都是参照世界坐标系或者由世界坐标系定义的(或者能够定义的)的笛卡尔坐标系

位置描述

  • 一旦建立了坐标系,我们就能用一个3 * 1的位置矢量对世界坐标系中的任何点进行定位。
  • 因为经常在世界坐标系中还要定义许多坐标系,因此必须在位置矢量上附加一条信息,表明是在哪一个坐标系被定义的。
  • 总之,我们用一个位置矢量来描述空间中点的位置。

姿态描述

  • 为了描述物体的姿态,我们将在物体上固定一个坐标系并且给出此坐标系相对于参考系的表达。
  • 因此,点的位置可用矢量描述,物体的姿态可用固定在物体上的坐标系来描述。
  • 总之,一组三个矢量可以用来确定一个姿态。简单起见,我们用三个矢量作为矩阵的列来构造一个3 * 3 的矩阵。
  • 于是,点的位置可用一个矢量来表示,物体的姿态可用一个矩阵来表示。

坐标系的描述

  • 我们可在物体上任选一点描述其位置,为方便起见,将其作为连体坐标系的原点。

  • 在机器人学中,位置和姿态经常成对出现,于是我们将此组合称为坐标系,四个矢量为一组,表示了位置和姿态信息。一个矢量表示指端位置,而另外三个矢量表示姿态。

  • 一个坐标系可以等价地用一个位置矢量和一个旋转矩阵来描述。

  • 坐标系可用三个标有箭头的单位矢量定义的坐标系的主轴来描述。从原点到另一点的箭头表示了一个矢量。这个矢量表示了箭头处的原点相对于箭尾所在的坐标系的位置

  • 总之,一个参考系可以用作一个坐标系相对于另一个坐标系的关系来描述。参考系包括了位置和姿态两个概念,大多数情况下被认为是这两个概念的结合

  • 位置可由一个参考系表示,这个参考系中的旋转矩阵是单位阵,并且这个参考系中的位置矢量确定了被描述点的位置。同样,如果参考系中的位置矢量是零矢量,那么它表示的就是姿态。

算子:平移,旋转和变换

  • 用于坐标系间点的映射的通用数学表达式称为算子,包括点的平移算子,矢量旋转算子和平移加旋转的算子

X-Y-Z固定角坐标系

  • 每个旋转都是绕着固定参考坐标系{A}的轴,我们规定这种姿态的表示法为X-Y-Z固定角坐标系。
  • 固定,一词是指旋转是在固定(既不运动)参考坐标系中确定的。有时把它们定义为回转角,俯仰角和偏转角

Z-Y-X欧拉角

  • 在这种表示法中,每次都是绕运动坐标系{B}的各轴旋转而不是绕固定坐标系{A}的各轴旋转。这样三个一组的旋转被称作欧拉角。
  • 注意每次旋转所绕的轴的方位取决于上次的旋转。由于三个旋转分别是绕着Z,Y和X,所以称这种表示法为Z-Y-X欧拉角

Z-Y-Z欧拉角

  • 相对于运动坐标系{B}的旋转描述是一个欧拉角描述。因为三个旋转依次绕Z,Y和Z,所以称此描述为Z-Y-Z欧拉角

第三章 操作臂运动学

概述

  • 操作臂运动学研究操作臂的运动特性,而不考虑使操作臂产生运动时施加的力。在操作臂运动学中,将要研究操作臂的位置,速度,加速度以及位置变量的所有高阶导数(对于时间或其他变量)

连杆描述

  • 操作臂可以看成由一系列刚体通过关节连接而成的一个运动链,我们将这些刚体称为连杆

驱动器空间,关节空间和笛卡尔空间

  • 对于一个具有n个自由度的操作臂来说,它的所有连杆位置可由一组n个关节变量加以确定。这样的一组变量常备称为n * 1 的关节矢量。所有关节矢量组成的空间称为关节空间。

  • 至此,我们关心的是如何将已知的关节空间描述转化为笛卡尔空间的描述。当位置是在空间相互正交的轴上测量,且姿态是按照第二章中的任何一种规定测量时,我们称这个空间为笛卡尔空间,有时称为任务空间和操作空间

  • 到目前为止,我们一直假设每个运动关节都是直接由某种驱动器驱动。然而,对于许多工业机器人来说并非如此。例如有时用两个驱动器以差动的方式驱动一个关节,有时候直线驱动器通过四连杆机构来驱动旋转关节。在这些情况下,就需要考虑驱动器位置。由于测量操作臂位置的传感器尝尝安装在驱动器上,因此进行某些计算时必须把关节矢量表示成一组驱动器函数,即驱动器矢量

  • 一个操作臂的位姿描述有三种表示方法:

    • 驱动空间描述
    • 关节空间描述
    • 笛卡尔空间描述

坐标系的标准命名

  • 为了规范起见,有必要给机器人和工作空间专门命名和确定专门的”标准”坐标系。一种典型的情况:机器人抓持某种工具,并把工具末端移动到操作者指定的位置。

  • 基坐标系{B}

    • 基坐标系{B}位于操作臂的基座上。它仅是赋予坐标系{0}的另一个名称。因为它固连在机器人的静止部位,所以有时称为连杆0
  • 工作台坐标系{S}

    • 工作台坐标系{S}的位置与任务相关。对机器人系统的用户来说,工作台坐标系{S}是一个通用坐标系,机器人所有的运动都是相对于它来执行的。
    • 有时称它为任务坐标系,世界坐标系或通用坐标系。工作台坐标系通常根据基坐标系确定
  • 腕部坐标系{W}

    • 腕部坐标系{W}附于操作臂的末端连杆。这个固连在机器人末端上的坐标系也可以称为坐标系{N}。
    • 大多数情况,腕部坐标系{W}的原点位于操作臂手腕上,它随着操作臂的末端连杆移动。它相对于基坐标系定义。
  • 工具坐标系{T}

    • 工具坐标系{T}附于机器人所夹持工具的末端。当手部没有夹持工具时,工具坐标系{T}的原点位于机器人的指端之间。工具坐标系通常根据腕部坐标系来确定
  • 目标坐标系{G}

    • 目标坐标系{G}是机器人移动工具时对工具位置的描述。特指在机器人运动结束时,工作坐标系应当与目标坐标系重合。
    • 目标坐标系{G}通常根据工作台坐标系来确定

工具的位置

  • 机器人的首要功能之一是能够计算它所夹持的工具(或未夹持工具)相对于规范坐标系的位姿,也就是说需要计算工具坐标系{T}相对于工作台坐标系{S}的变换矩阵

计算问题

  • 在许多实际的操作臂系统中,求解运动学方程所需的时间是一个必须考虑的问题。

  • 一种选择就是使用定点或浮点数表示相关的变量。为便于软件编制,许多计算采用浮点运算,因为程序员不需考虑进行标量运算时变量所占用的相对范围。

  • 通过分量方程,利用局部变量可以减少乘和加的次数(这样做是经济的),这时为了避免计算机重复运行相同的语句。分量方程已在工厂的计算机辅助制造中得到应用。

  • 运动学计算的主要耗时是计算超越函数(正弦和余弦)。当这些函数作为标准库的一部分时,常需要以成倍的耗时进行一系列展开式的计算。这时操作系统可以提供一些内存,用查表的方式计算超越函数

第四章 操作臂逆运动学

引言

  • 逆运动学问题:
    • 已知工具坐标系相对于固定坐标系的期望位置和姿态,如何计算一些列满足期望要求的关节角

简介

  • 计算机程序的构造和解释 书籍的阅读笔记

第一章 程序设计的基本元素

  • 一个强有力的程序设计语言,不仅是一种指挥计算机执行任务的方式,它还应该称为一种框架,使我们能够在其中组织自己有关计算过程的思想。

  • 每一种强有力的语言都为此提供了三种机制:

    • 基本表达形式,用于表示语言所关心的最简单的个体
    • 组合的方式,通过它们可以从较简单的东西出发构造出复合的元素
    • 抽象的方法,通过它们可以为复合对象命名,并将它们当做单元去操作
  • 在程序设计中,我们需要处理两类要素:过程和数据

简介

  • 操作系统概念 第九版 ,作者为 Abraham Silberschatz, Peter B.Galvin,

第一部分 概论

  • 操作系统位于计算机用户与计算机硬件之间。操作系统的目的是提供环境,以便用户能够便捷且高效地执行程序

第一章 导论

  • 由于操作系统既庞大又复杂,应一部分一部分地构造。每一部分都应具有明确描述的系统部分,而且输入,输出及功能都有明确的定义。

1.1 操作系统的功能

  • 计算机系统可以粗分为四个组件:硬件,操作系统,应用程序和用户

  • 硬件(hardware),例如中央处理单元(Center Processing Unit, CPU),内存(memory),

  • 输入/输出设备(Input/Output device, I/O Device),为系统提供基本的计算资源

  • 应用程序(application program),例如字处理程序编译器,网络浏览器,规定了用户为解决计算问题而使用这些资源的方式。操作系统控制硬件,并且协调各个用户应用程序的硬件使用。

  • 从计算机的视角来看,操作系统是与硬件紧密相连的程序。因此,可以将操作系统看作资源分配器(resource allocator)

  • 操作系统的另一个稍有不同的视角是,强调控制各种I/O设备和用户程序的需求。操作系统是个控制程序。控制程序(control program)管理用户程序的执行,以防止计算机资源的错误或不正当使用。

  • 操作系统的定义

    • 操作系统是一直运行在计算机上的程序(通常成为内核(kernel)).
    • 除了内核外,还有其他两类程序:系统程序(system program)和应用程序
    • 系统程序是与系统运行有关的程序,但不是内核的一部分;
    • 应用程序是与系统运行无关的所有其他程序

1.2 计算机系统的组成

  • 现代通用计算机系统包括一个或多个CPU和若干设备控制器,通过公有总线相连而成,该总线提供了共享内存的访问。

  • 每个设备控制器负责一类特定的设备。CPU与设备控制器可以并发执行,并且竞争访问内存。

  • 为了确保有序访问共享内存,需要内存控制器来协调访问内存。

  • 当计算机电源打开或者重启以便开始运行时,它需要运行一个初始程序。该初始程序或者引导程序(booststrap program)通常很简单,一般位于计算机的固件(firmware),例如只读内存(Read-Only Memory, ROM)或者电可擦可编程只读内存(Electrically Erasable Programmable Read-Only Memory, EEPROM)。

  • 它初始化系统的各个组件,从CPU寄存器,设备控制器到内存内容。引导程序必须知道如何加载操作系统并且开始执行系统。为了完成这一目标,引导程序必须定位操作系统内核并且加到内存。

  • 一旦内核加到内存并且执行,它就开始为系统与用户提供服务。除了内核外,系统程序也提供一些服务,他们在启动时加到内存而成为系统进程(system process)或者系统后台程序(system daemon),其生命周期与内核一样。对于UNIX,首个系统进程为init,它启动许多其他系统的后台程序。一旦这个阶段完成,系统就完全启动了,并且等待事件发生

  • 事件发生通常通过硬件或者软件的中断(interrupt)来通知。硬件可以随时通过系统总线发送信号到CPU,以触发中断。软件也可以通过执行特别操作即系统调用(system call)(也称为监督程序调用(monitor call)),以触发中断

  • 中断是计算机体系结构的重要部分。虽然每个计算机设计都有自己的中断机制,但是有些功能是共同的。中断应该将控制转移到合适的中断服务程序。处理这一转移的直接方法是,调用一个通用程序以检查中断信息。接着,该程序会调用特定的中断处理程序。

简介

  • 应用机器人学-运动学-动力学与控制技术 阅读笔记

第一章 概述

  • 美国机器人学研究所给出了机器人的定义:机器人是一个可重复编程的多功能操纵器,为了执行不同的任务,通过不同的程序驱动可用于移动材料、工具或者专业化装置。
  • 从工程的角度来说,机器人是一个复杂的通用装置,它包含了机械机构、传感系统和自动控制系统。机器人学的理论基础包括:力学、电气科学、自动控制、数学和计算机科学。

机器人元器件

  • 在运动学上,机器人手臂是由连接关节的连杆所组成的,以形成一个运动链。

连杆

  • 构成机器人的刚体被称为连杆。在机器人学中,我们有时使用机械臂表示连杆

关节

  • 两个连杆在关节处通过接触而连接,在关节处它们的相对运动可用同一坐标表示。

  • 典型的关节要么是旋转的,要么是棱柱的。

    • 旋转关节R,就像一个铰链,允许在两个连杆之间有相对的旋转
    • 棱柱关节P,允许在两连杆之间有相对移动
  • 如果旋转关节将两连杆连接起来,它们将绕着一条线发生相对转动,这条线称为关节轴线。

  • 如果棱柱关节将两连杆连接起来,它们将沿着一条直线发生移动,这条线也称为关节轴线

  • 在关节处,描述两被连接连杆的单一坐标值称为关节坐标或者关节变量。

    • 对于旋转关节,关节变量是一角度
    • 对于棱柱关节,关节变量则是距离
  • 主动关节的坐标由驱动器控制,而从动关节则没有驱动器。

  • 从动关节变量是主动关节变量和机械臂几何参数的函数。因此,从动关节也可称为非主动关节或者自有关节

  • 主动关节通常是移动或者转动,然而从动关节可以是任何能够提供面接触的低副关节,分别为:

    • 旋转副
    • 移动副
    • 圆柱副
    • 螺旋副
    • 球副
    • 平面副
  • 旋转副关节和移动副关节是最常用的关节,它们用在串联机械手中。

  • 其他的关节类型只不过是为了完成相同的功能或者提供附加自由度的一个实现

  • 棱柱关节(移动副)和旋转关节(旋转副)提供一个自由度,因此一个机械手的关节数就是该机械手的自由度。

  • 典型的机械手应该至少有6个自由度,3个自由度用于定位,3个自由度用于定向。

  • 具有6个以上自由度的机械手在运动学上就是冗余机械手

机械手

  • 由连杆,关节和其他结构零部件所构成的机器人主题称为机械手。当一个机械手上装了手腕,夹持器和控制系统时,该机械手就变成了一个机器人

机械手腕

  • 在前臂与末端执行器之间的机器人运动链中的关节指的就是手腕。
  • 用球关节设计机械手是普遍的做法

末端执行器

  • 末端执行器是安装在最后一根连杆上的元件,用于完成机器人所要求的工作。

  • 最简单的末端执行器就是夹持器,它通常只有两个动作:张开和闭合

  • 机器人中机械臂和手腕的装配基本上用于定位末端执行器或者能够执行的任何机具,就是末端执行器或者机具实际执行任务

驱动器

传感器

控制器

工作空间

  • 机械手工作空间是指末端执行器所能到达的空间体积。工作空间受限于机械手结构和机械的关节约束。

  • 工作空间被分为可到达的工作空间和灵活的工作空间。

    • 可到达的工作空间就是在至少一个方向由末端执行器所引起的每个关节所到达的空间体积
    • 灵活的工作空间是指在所有可能的方向上由末端执行器所引起的每个关节所到达的空间体积。
  • 灵活的工作空间是可能到达工作空间的一个子集

机器人运动学,动力学,控制概述

  • 正向运动学问题就是求解关节坐标的运动学参数,这些运动学参数再用来求解笛卡尔坐标系下的相关参数

  • 逆向运动学问题就是求解笛卡尔空间中末端执行器的运动学参数,这些运动参数在关键空间中是必须的。

  • 运动学(Kinematics),是分析运动科学的一个分支,该学科不注重什么原因引起的运动。

  • 通过运动我们表示任何类型位移,包括空置和方向的改变。因此,位移,相对于时间的连续导数,速度,加速度,加加速度,所有的这些都归入运动学

  • 定位,就是使末端执行器在工作空间范围内到达一个任意点。而定向,就是使末端执行器移向在某位置处所要求的方向。

    • 定位是机械臂的工作
    • 定向是手腕的工作
  • 动力学是用来研究系统随着时间变化所经历的状态变化的

  • 机器人控制包括以下三个计算问题:

    • 笛卡尔坐标系中轨迹的确定
    • 从笛卡尔轨迹到等效关节坐标空间的转换
    • 形成实现轨迹的电动机转矩指令

参考坐标系和坐标系统

  • 在机器人学中,我们可在机器人的每根连杆上和机器人环境中的每个物体上建立一个或者更多的坐标系。

  • 如此一来,坐标系间的转换,称为坐标转换,这是机器人建模与编程中的一个基本概念。

  • 刚体的角运动可以用好几种方式描述,最常用的方式如下:

    • 关于右手定则的全局固定直角坐标轴的一组旋转运动
    • 关于右手定则的运动直角坐标轴的一组旋转运动
    • 空间中固定轴的角位移
  • 矢量和参考坐标系是分析复杂系统运动的主要工具,特别是当运动是三维空间的以及涉及许多零部件时

  • 坐标系是由一组基本的矢量所定义的,例如沿着三个坐标轴的单位矢量。因此旋转矩阵作为坐标变换矩阵,也可以用来定义从一个坐标系到另一个坐标系的基本变化

  • 旋转矩阵可用以下三种方式来解释:

    • 映射。它主要表述坐标转换,映射和点P在两个不同坐标系中的相关坐标
    • 坐标描述。相对于固定坐标系,它给出变换坐标系的定向
    • 算法。将一个矢量旋转,形成一个新的矢量
  • 刚体旋转可用 旋转矩阵R,欧拉角(Euler angles),角轴公约(angle-axis convention)以及四元数(quaternion)来描述,每种方法都有其优缺点

  • 在基体变化中,旋转矩阵R是正向解释,与此同时其缺点是必须知道9个合成的参数。每个单独参数失去了其物理意义,只有在整体矩阵上才有意义

  • 欧拉角是通过由绕着局部 ( 有时全局)坐标系的 3 个坐标轴进行各自连续旋转来粗略定义的。使用欧拉角的优点是旋转运动可由 3 个具有普通物理意义的独立参数描述。其缺点是描述不独立并会产生奇异性问题。计算复合旋转也并不是一件简单的事情,除扩展成矩阵外。

  • 角轴是能最直观地描述旋转运动的。但是,它要求 4 个参数能合成为一个单一的旋转运动,合成旋转运动的计算并不简单,对于小旋转来说它是病态的

  • 四元数在直观保存角轴方面是很好的,并能克服小旋转的病态,确认一组结构允许合成旋转运动的计算。四元数的缺点是 4 个参数必须能够表述一个旋转运动。参数化比角轴更复杂,并且有时失去了其物理意义。四元数相乘并不像矩阵相乘那样简单明了

第二章 旋转运动学

全局翻滚角,俯仰角,偏航角

  • 绕全局坐标系X轴的旋转被称为翻滚(roll),绕全局坐标系Y轴的旋转被称为俯仰角(pitch),绕全局坐标系Z轴的旋转被称为偏航(yaw)。

欧拉角

  • 绕全局坐标系的Z轴的旋转被称为旋进(即进动,precession),绕局部坐标系的x轴的旋转被称为章动(nutation),绕局部坐标系z轴的旋转被称为自转(spin)

  • 旋进章动自转角(precession-nutation-spin rotation angles)也称为欧拉角。

第三章 定向运动学

第四章 运动的运动学

螺旋坐标

  • 任何刚体运动都可以由沿着某轴的单一平动和绕着该轴的单一旋转运动合成产生。这称为沙勒定理(Chasles theorem)。这样的运动被称为螺旋运动

第五章 正向运动学

  • 对于给定的一组机器人几何特征,如果指导机器人节点变量,就能够确定机器人中每根连杆的位置和方向。我们可以在每根连杆上建立一个坐标系,并且通过刚体运动方法来确定相邻坐标系的配置,这样的分析被称为正向运动学

机器人正向位置运动学

  • 正向或者直接运动学是旋转运动中从机器人关节变量空间到笛卡尔坐标系空间的运动变换。

  • 对于一组给定的关节变量,求取末端执行器的位置和方向是正向运动学的主要问题。这个问题可以通过求取用于描述基体连杆坐标系中连杆运动信息的变换矩阵而得到解决

  • 对于机械手,列写正向运动学方程的传统方法是通过D-H标记和坐标系处理连杆而获得的。因此,正向运动学是基本的变换运算

  • 运动学信息包括位置,速度,加速度和突变。然而,正向运动学通常指的是位置分析,因此正向运动学等效于确定一个综合变换矩阵

组装运动学

  • 大部分现代工业机器人都有一个主要的机械手和一系列互变的手腕。机械手是多体的,以便他能抓住主要的力学单元,并且为手腕提供一个强有力的运动。

  • 可变手腕是复杂多体,主要用来提供绕在腕点的三个旋转自由度。手腕基本连接至机械手的末端点。

  • 机器人的手腕,实际操作不见也可以称为末端执行器

第六章 逆向运动学

  • 对于给定配制的机器人来说,什么是关节变量呢?这就是逆向运动学研究的问题。

  • 确定关节变量能够简化求解一组非线性耦合代数方程。

  • 对于求解逆向运动学问题而言,虽然没有标准方法和常规的应用方法,但是仍有一些求解该问题的分析方法和数值解法。

  • 逆向运动学的主要困难是多解

解耦技术

  • 依据末端执行器的位置和方向确定关节变量,称之为逆向运动学。数学上,逆向运动学主要寻找矢量q中的元素

  • 计算机控制的机器人通常在关节变量空间中被驱动,然而通常在全局笛卡尔坐标系中表述被操作的物体。

  • 因此,在机器人学中,必须携带关节空间和笛卡尔空间之间的运动信息。为了控制末端执行器到达一个物体的配置,必须求解逆向运动。

  • 因此,我们需要知道在期望的方向上达到期望点所需的关节变量是什么

  • 6自由度机器人的正向运动结果是一个4 * 4的变换矩阵

  • 有可能将逆向运动学问题解耦成两个自问题,即众所周知的逆向位置运动学问题和逆向方向运动学问题。

  • 这样解耦的一个实际结果就是将这个问题分解为两个独立的问题,每个问题只有3个未知数。按照解耦原理,机器人的综合变换矩阵可以分解为一个平动和一个转动。

逆向运动技术比较

  • 显然,当期望的末端执行器坐标位置超出了机器人的工作范围时,机器人关节变量没有任何实解。在这种情况下,平方根符号将使综合结果为负。

  • 因此,通常来说,逆向运动学问题是否存在解取决于机器人的几何配置

  • 正常情况即当关节数量为6时。假设没有多余的自由度,机器人末端执行器的配置在工作空间范围之内,逆向运动学的解有无数多个。为了达到同一末端执行器空间位置,不同解均相当于可能的配置

  • 总的来说,当机器人逆向运动学的解存在时,其解不是唯一的。出现多解,这是因为机器人以不同的配置可以达到工作空间范围内的同一点

逆向运动技术

  • 可以用很多种方法求解机器人的逆向运动学问题,例如解耦,逆变换,迭代,螺旋代数,双重矩阵,双重四元数和其他几何技术。

  • 迭代法通常要求大量的计算,而且他并不能保证收敛于正确解。机器人几乎不可能接近于齐次且衰退的配置。迭代求解法也缺乏从多个可能的解中选择最合适解的方法

奇异配置

  • 总的来说,对于任何机器人来说,冗余与否均有可能发现一些配置,称之为奇异配置。

  • 在奇异配置总,末端执行器自由度的数目相对于产生动作的维数是次要的。当下列情况发生时会发生奇异配置:

    • 棱柱关节的两个轴是平行的,
    • 旋转关节的两个轴是相同的。
  • 在奇异位置处,末端执行器失去一个或者更多的自由度,因为运动方程是线性相关的或者是不确定的。随着移动末端执行器所要求的速度理论上是无限的时,必须避免奇异位置。

  • 雅可比矩阵不再满足秩的配置相当于机器人具有奇异性,通常有两种类型:

    • 工作空间边界奇异性(Work-space boundary singularities)。当机械手全部伸出或者自身完全折回时,便会出工作空间边界奇异性。在这种情况中,末端执行器接近或者就在工作空间边界处
    • 工作空间内部奇异性(Work-space interior singularities)。这种情况出现在远离边界处。在这种情况中,通常有两个或者更多轴排成了一行
  • 在机器人学中,辨别和避开奇异性配置是非常重要的。主要原因如下:

    • 运动的确定方向无法达到
    • 一些关节速度是无限的
    • 一些关节转矩是无限的
    • 逆向运动学问题不存在唯一解

第七章 角速度

简介

  • 尽量避免前置声明那些定义在其他项目中的实体

  • 只有当函数只有10行甚至更少时才将其定义为内联函数

  • #include的包含顺序:

    1. C系统文件
    2. C++系统文件
    3. 其他库的.h文件
    4. 本项目内的.h文件
  • 局部变量:将函数变量尽可能置于最小作用域内,并在变量声明时进行初始化

    1. Warning:有一个例外,如果变量是一个对象,每次进入作用域都要调用其构造函数,每次退出作用域都要调用其析构函数,这样会导致效率降低
    2. Solution:在循环作用域外面声明这类变量要高效的多
  • 类:构造函数不允许调用虚函数

    1. 隐式类型转换:不要定义隐式类型转换,对于转换运算符和单参数构造函数,请使用explicit关键字
    2. 结构体和类:仅当只有数据成员时使用struct,其他一概使用class
  • 编写简短函数:

    • 我们倾向于编写简短、凝练的函数。长函数有时是合理的,因此并不硬性限制函数的长度;
    • 如果函数超过40行,可以思考一下能不能在不影响程序结构的前提下对其进行分割。
  • 所有按引用传递的参数必须加上const

  • 类型转换:使用C++的类型转换,如static_cast<>(),不要使用int = (int)x

  • 流:不要使用流,除非是日志接口需要;使用 printf 之类代替

  • const变量,数据成员、函数和参数为编译时类型检测增加了一层保障,便于尽早发现错误,因此强烈建议在任何情况下使用const

  • C++中,用constexpr来定义真正的常量,或实现常量初始化

  • auto绕过繁琐的类型名,只要可读性好就继续用,别用在局部变量之外的地方

  • 通用命名规则:函数命名、变量命名、文件命名要有描述性,少用缩写

    • 文件名,要全部小写,包含下划线或连字符;
    • C++文件要以.cc结尾,头文件以.h结尾
    • 类型名称,每个单词首字母均大写,不包含下划线,例如:MyExcitingClass
    • 变量名、函数参数和数据成员名一律小写,单词之间用下划线连接;
    • 类的成员变量以下划线结尾,但结构体就不用,例如:a_local_variable, a_struct_data_member, a_class_data_member_
    • 常量命名:声明为constexprconst的变量,或在程序要运行期间其值始终保持不变的,命名时以k开头,大小写混合,例如:const int kDaysInAWeek = 7;
    • 常规函数使用大小写混合,取值或设值函数则要求与变量名匹配,一般来说,函数名的每个单词首字母均大写
  • 在每一个文件开头加入版权公告

  • 每个类的定义都要附带一份注释,描述类的功能和用法,除非它的功能相当明显

  • 函数注释,函数声明处的注释描述函数功能;定义处的注释描述函数实现

  • 条件语句:不在圆括号内使用空间,关键字if和else另起一行

  • 预处理指令:预处理指令不要缩进,从行首开始

  • 不到万不得已,不要使用空行,函数之间的空行不要超出2行

简介

异步操作(asynchronous operation)

  • 是由于很多计算机系统事件会在不可预测的事件,以不可预测的顺序发生而产生的

并发(concurrency)

  • 是指在相同的时间帧内对资源的共享
  • 并发实体,可以是单个程序内部的执行线程或者其他抽象的对象
  • 并发可能发生在单CPU系统,共享相同内存的多CPU系统,或者运行在网络上的独立系统中

通信(communication)

  • 将一个实体的信息传送给另一个实体
  • 程序在处理磁盘这样的本地设备的I/O的同时,还必须要处理网络I/O(网络通信)

上下文切换时间(context-switch time)

  • 是指从执行一个进程转换到执行另一个进程所花费的时间

时间片(quantum)

  • 大致上就是在一个进程不得不让出处理器让其他进程运行之前,分配给这个进程的CPU时间总量

多道程序设计(multiprogramming)

  • 指由于处理时间上的悬殊差异,采取有多个进程准备好要执行,操作系统挑选一个已经准备好的进程来执行,当哪个进程需要等待资源时,操作系统保存从停止处回复此进程所需的所有信息,并选择另一个准备好的进程执行

  • 一次资源请求会引起一次对操作系统的请求(即一次系统调用)

系统调用(system call)

  • 是对操作系统服务的一次请求,它会使正常的CPU周期中断,并将控制权交给操作系统,然后,操作系统就可以切换到另一个进程了

分时(timesharing)

  • 单物理CPU,并发

多处理器系统(multiprocessor systems)

  • 几个处理器都访问一个共享的内存

硬件层并发

  • 由于有多台设备要同时操作
  • 处理器中有内部的并行机制,可以同时处理几条指令,系统中有多个处理器,而且系统通过网络通信进行交互

应用层并发

  • 在信号处理中,I/O与其他处理的重叠中,在通信过程中,在进程间或同一进程的不同线程间的资源共享中,都存在应用层的并发

中断(interrupt)

  • 在常规机器层(conventional machine level)程序中,单指令的执行是处理器指令周期(processor instruction cycle)的结果
  • 在处理器指令周期的正常执行过程中,处理器从程序计数器中检索出一个地址,并执行这个地址上的指令。
  • 在常规机器层出现并发,是因为外围设备会产生一种被称为中断的电信号,在处理器内部设置一个硬件标志符。
  • 检测中断是指令周期自身的一部分。在每个指令周期中,处理器都检查硬件标识。
  • 如果处理器察觉有中断发生,它就保存程序计数器的当前值,并装载一个新的值,这个新的值是一个被称为中断服务例程(interrupt service routine)或中断处理程序(interupt handler)的特殊函数的地址

异步(asynchronous)

  • 如果一个事件发生的时间不是由某个实体确定的,那么这个事件就是异步于这个实体的。
    • (外部硬件设备产生的中断通常都异步于系统中执行的程序)

同步(synchronous)

  • 如果向指令提供相同的数据,那么,像被零除这样的错误事件,就总是在执行某个特殊指令的时候发生,从这种意义上来说,错误事件是同步的

设备驱动程序(device driver)

  • 被称为设备驱动程序的操作系统例程,通常用来处理外围设备产生的中断。
  • 然后这些驱动程序会通过信号这样的软件机制,来通知相关的进程事件已经发生了

定时器(timer)

  • 操作系统也用中断来实现分时。
  • 大多数计算机都有一个被称为定时器的设备,它可以在一段指定的时间间隔后产生中断。
  • 为了执行用户程序,操作系统在设备程序计数器之前启动定时器。定时器到时的时候,它就产生一个中断,使CPU转而执行定时器中断服务例程。中断服务例程将操作系统代码的地址写入程序计数器,这样,操作系统又获得了控制权

信号(signal)

  • 是事件的软件通知
  • 通常,信号是操作系统对中断(硬件事件)的响应
  • 例如,按下Ctrl-C键会使处理键盘的设备驱动程序产生一个中断。驱动程序将这些字符当做中断字符,并发送信号来通知与这个中断相关的进程
  • 当引发信号的那个事件发生时,信号就产生了(generate)了 – 信号可以同步产生,也可以异步产生
    • 如果信号由接收它的进程或线程产生,这个信号就是同步产生的。执行非法指令都会产生同步信号
    • 在键盘上输入Ctrl-C会产生一个异步信号

捕捉(catch)

  • 进程执行信号的处理程序时,它就捕捉到了信号
  • 捕捉信号的程序至少有两个并发的部分,主程序和信号处理程序

进程,线程和资源共享

  • 在UNIX中实现并发执行的一种传统方法是:用户通过调用fork()函数创建多个进程。
  • 有相同祖先的进程可以通过管道(pipe)进行通信
  • 没有共同祖先的进程可以通过信号,FIFO,信号量,共享的地址空间或消息进行通信
  • 在进程内部可以通过多个执行线程提供并发。
  • 程序执行时,CPU用程序计数器来确定下一步要执行哪条指令。得到的指令流被称为程序的执行线程(thread of execution)。它是进程的控制流

分布式计算

  • 并发和通信共同形成新的应用程序
  • 在分布式计算中使用最广泛的模型是客户端-服务器模型(client-server model)。这个模型中的基本实体
    • 有管理资源的服务器进程,
    • 和需要对共享资源进行访问的客户机进程
  • 基于对象的模型(object-based model)是分布式计算的另一种模型
    • 系统中的每种资源都被看作一个带有消息处理接口的对象,这样就可以用统一的方式来访问所有的资源
    • 基于对象的模型允许进行受控的增量开发和代码重用

缓冲区溢出(buffer overflow)

  • 当程序将数据拷贝到一个没有为其分配足够空间的变量中去的时候,就会发生缓冲区溢出

  • 缓冲区溢出的后果

    • 要理解缓冲区溢出时会发生什么情况,就要理解程序在内存中是如何布局的
      • 大多数程序代码都在带有自动局部变量的函数中执行
      • 虽然在不同的机器上实现的细节有所不同,程序通常都在程序栈上分配自动变量
    • 在典型系统中,栈都是从高端内存向低端内存扩展的
    • 调用一个函数时
      • 栈的低端部分包括传递的参数和返回地址
      • 栈中较高的部分(内存地址比较小的部分)用来存放局部自动变量
    • 栈可以用来存储其他值,也可能包含根本不为程序所用的间隙
    • 一个很重要的事实是:
      • 每次函数调用的返回地址通常都存储在自动变量后面的内存中(存储在地址比较大的内存中)
    • 当程序向栈中变量的范围之外写入时,回复阿生缓冲区溢出。额外的字节可能会重写未使用的空间,其他变量,返回地址或该程序不能合法访问的其他内存。
    • 结果可能是没什么影响,也可能会造成程序崩溃,信息转储以及不可预测的行为

程序,进程和线程

程序(program)

  • 指的是为了完成特定的任务而准备好的一个指令序列

  • C编译器将每个源文件翻译成一个目标文件,然后编译器将这些单个的目标文件同必须的一些库链接,形成一个可执行模块(executable module)。程序运行或执行(execute)时,操作系统将可执行模块拷贝到主存储器的程序映像(program image)中去

进程(process)

  • 是一个正在执行的程序实例
  • 每个实例都有自己的地址空间和执行状态
  • 操作系统记录进程ID和相应的进程状态,并用这些信息来分配和管理系统资源。操作系统还要对进程占用的内存和可分配的内存进行管理
  • 当操作系统向内核数据结构中添加了适当的信息,并为运行程序代码分配了必要的资源之后,程序就变成了进程。
  • 程序拥有地址空间(它可以访问的内存)和至少一个被称为线程的控制流
  • 进程,以执行一个指令序列的控制流开始。处理器程序计数器记录处理器(CPU)要执行的下一条指令。CPU读取一条指令后,对程序计数器的值进行增量运算,并且在指令的执行过程中,比如,在出现分支的时候,还会对其做进一步的修改。
  • 可能有多个进程驻存在内存中并发地执行,他们基本上都互相独立。如果进程要进行通信或互相合作,它们就必须显式地通过文件系统,管道,共享内存或网络这样的操作系统结构来交互

线程和执行线程(thread of execution)

  • 程序执行时,由进程程序计数器的值来决定下面该执行哪一条进程指令。得到的指令流被称为执行线程
  • 它可以用程序代码执行期间为程序计数器指定的指令地址序列来表示
  • 执行线程中的指令序列对进程来说,就像是一条不间断的地址流。但从处理器的观点来看,来自不同进程的执行线程是混在一起的。
  • 执行从一个进程切换到另一个进程的点被称作上下文切换(context switch)
  • 线程,是代表了进程内执行线程的一种抽象数据类型。线程有自己的执行栈,程序计数器值,寄存器组和状态
  • 通常在一个进程范围内声明多个进程,程序员可以编写出以很低的开销获得并行性的程序。
  • 尽管这些线程提供了低开销的并行性,但由于它们驻留在相同的进程地址空间并共享进程资源,因此,可能还需要对它们进行额外的同步。
    • 由于启动进程所需要的工作量大,有些人将进程称作是重量级(heavyweight)
    • 与之相反,线程有时被称作轻量级进程(lightweight processes)

程序映像的布局

  • 加载之后,可执行程序看起来占据了一个连续的内存块,这个连续的内存块被称为程序映像(program image)
  • 程序映像有几个不同的分区。程序文本或代码显示在内存低端地址中。在映像中已经初始化和未初始化的静态变量也有自己的分区。其他的分区包括堆,栈和环境
  • 活动记录(activation record)
    • 指的是在进程栈顶端分配的一个内存块,用来装载调用过程中函数的执行上下文。
    • 每次函数调用都在栈上创建一个新的活动记录
  • 除了静态变量和自动变量之外,程序映像中还包括了argcargv占用的空间以及malloc分配的空间。
  • malloc函数族在一个被称为堆(heap)的空闲内存池中分配存储空间
    • 在堆上分配的存储空间一直存在,直到它被释放或程序退出为止
    • 如果一个函数调用了malloc,那么在这个函数返回值后,存储空间仍保持已分配状态。
    • 除非程序有一个在函数返回值后仍然可以访问的,指向该存储空间的指针,否则,返回后的程序就不能访问它
  • 在声明时,没有显式初始化的静态变量在运行时被初始化为0
  • 在程序映像中,已初始化的静态变量和未初始化的静态变量占据不同的分区
    • 通常,已初始化的静态变量是磁盘上可执行模块的一部分,而未初始化的静态变量则不是
    • 自动变量不是可执行模块的一部分,因为只有当定义它们的程序块被调用时,它们才会被分配。除非程序显式地对自动变量进行初始化,否则,它们的初始值是不确定的
  • 对线程化的执行来说,静态变量会使程序变得不安全。
  • 连续调用一个引用了静态变量的函数会出现意料不到的情况,因此,外部静态变量也使得代码的调试更加困难。
  • 出于这些原因,除非是在受控的情况下,否则应该避免使用静态变量
  • 尽管程序映像看起来占据了一个连续的内存块,但实际上,操作系统将程序映像映射到不一定连续的物理内存块中。
  • 通常的映射将程序映像划分成相同大小的片,这些片被称为页(page)
    • 操作系统将这些页加载到内存中,当处理器引用某页上的内存时,操作系统会从一个表中查找这一页的物理位置。
    • 这种映射方式允许栈和堆有很大的逻辑地址空间。
    • 操作系统隐藏了这种底层映射的存在,这样即使有些页实际上并没有驻留字内存中,程序员也可以认为程序映像在逻辑上是连续的

函数返回值和错误

  • 错误处理是编写可靠系统程序中的一个关键问题
  • 处理UNIX程序中错误的标准方法有如下几种
    • 打印出错误消息并退出程序(仅在main函数中)
    • 返回-1NULL,并设置errno这样的错误提示符
    • 返回错误码
  • 总的来说,函数永远也不能自己退出,而是应该向调用它的程序报告错误
  • 函数内部的错误消息在调试阶段可能会很有用,但通常不应该出现在最终版本中。
  • 处理调试信息有一种很好的方法:
    • 将调试打印语句包含在一个条件编译块中,这样在需要的时候可以将其重新激活

参数数组(argument array)

  • 是一个指向字符串的指针数组
  • 数组的结尾由一个包含NULL指针的条目来标识。

静态变量的使用

  • 静态变量可以用来存储函数调用之间的内部状态信息

进程环境

  • 环境列表(environment list)由一个指针数组组成,其中的指针指向 名字=值(name=value) 形式的字符串。数组的最后一个条目为NULL
    • 名字,指定一个环境变量(environment variable)
    • 值,指定与环境变量相关的字符串的值
  • 如果进程由execl, execlp, execv, execvp初始化,那么进程就继承了执行exec之前的那个进程的环境列表
  • 环境变量提供了一种用系统特定信息或用户特定信息在程序内部设置默认值的机制。例如,程序可能需要在用户的主目录中写入状态信息,或者需要在特定的地方查找一个可执行文件。用户可以在一个变量中设置信息,用以说明在哪里可以找到可执行文件。应用程序用其特有的方式来解释环境变量的值
  • getenv()来确定在进程环境中,一个指定的变量是否有值。将环境变量的名字作为字符串来传递
  • 不要将环境变量与预定义的常量混淆
    • 预定义的常量是用#define在头文件中定义的,它们的值是常数,在编译时是已知的,要想查看这样一个常量的定义是否存在,可以使用编译器指令#ifndef
    • 与之相反,环境变量是动态的,直到运行时才能直到它们的值

POSIX环境变量及其含义

  • COLUMNS – 终端上列的优选宽度
  • HOME
  • LINES – 页或垂直屏幕上的优选行数
  • LOGNAME – 与进程相关的登录名
  • PATH – 用于寻找可执行文件的路径前缀
  • PWD – 当前工作目录的绝对路径名
  • SHELL – 用户优选的命令解释程序的路径名
  • TERM – 输出的终端类型
  • TMPDIR – 临时文件目录的路径名
  • TZ – 时区信息

进程终止

  • 进程终止时,操作系统释放进程资源,更新适当的统计信息并向其他进程通知进程的死亡

  • 终止可以是正常的,也可以是不正常的。进程终止期间执行的动作包括

    • 取消挂起的定时器和信号
    • 释放虚拟内存资源
    • 释放其他进程持有的系统资源(例如锁)
    • 关闭打开的文件
  • 操作系统记录进程状态和资源的使用情况,同时通知父进程对wait函数进行响应

  • 在UNIX中,进程终止后不会完全释放它的资源,直到父进程等待它为止。

  • 如果进程终止的时候,它的父进程没有等待它,那么这个进程就成为一个僵进程(zombie)。

  • 僵进程是一个不活动的进程,它的资源会在稍后父进程等待它的时候被删除。一个进程终止时,它的孤儿子进程(orphaned child)和僵进程会被一个特殊的系统进程收养。

  • 在传统的UNIX系统中,这个特殊的进程被称为init进程,它的进程ID值为1,并周期性地等待子进程

  • 进程正常终止:

    • 从main中return
    • 从main中隐式地返回(main函数执行到末尾)
    • 调用exit, _Exit或_exit
  • C的exit函数调用了用户定义的退出处理程序,这些处理程序是由atexit()按照与登记时相反的顺序记录的

  • 调用了用户定义的处理程序之后,exit对任何一个包含未写入的缓冲数据的打开的流(open stream)进行刷新,然后关系所有打开的流。最后,exit删除所有tmpfile()创建的临时文件,并终止控制进程。

Unix系统中的进程

进程标识

  • UNIX用唯一的被称为进程ID(process ID)的整数值来标识进程

  • 每个进程还有一个父进程ID(parent process ID),这个父进程ID最初是创建它的那个进程的进程ID

  • 返回进程和父进程函数:getpid(), getppid()

  • 系统管理员创建用户账户时,为每个用户分配唯一的整型用户ID(user ID)和整型组ID(group ID)

  • 系统通过用户ID和组ID从系统数据库中检索出允许这个用户使用的权限。

  • 返回用户ID和组ID的函数:getegid(), geteuid()

进程状态

  • 进程的状态(state)说明了它在某个特定时刻的状况

  • 进程执行I/O时是通过一个库函数去请求服务的,这个库函数有时被称为系统调用(system call)

  • 在系统调用的执行过程中,操作系统重新获得对处理器的控制权,并且可以将进程转入阻塞状态,直到操作结束为止

  • 上下文切换(context switch),是指将一个进程从运行状态移出,并用另一个进程来替代它的行为

  • 进程上下文(process context),是操作系统在上下文切换之后重启进程所需的,有关此进程及其环境的信息

    • 很明显,就像用于静态和动态变量的内存的当前状态一样,可执行代码,栈,寄存器和程序计数器都是上下文的一部分
    • 为了能够透明地重启进程,操作系统还要记录进程状态,程序I/O的状况,用户和进程的标识,权限,调度参数,账号信息以及内存管理信息
    • 如果进程在等待事件或者已经捕捉到了一个信号,那么这个信息也是上下文的一部分
    • 上下文还包括与其他资源相关的信息,例如进程持有的锁等

Unix进程的创建与fork调用

  • 进程可以通过调用fork来创建新的进程

  • 调用进程就称为父进程(parent),被创建的进程就被称为子进程(child)

  • fork函数拷贝了父进程的内存映像,这样新进程就会收到父进程地址空间的一份拷贝。两个进程在fork语句之后,都继续执行后面的指令(分别在它们自己的内存映像中执行)

  • 子进程继承(inherit)了诸如环境和权限这样的父进程属性,还继承了某些父进程资源,例如打开的文件和设备

wait函数

  • 一个进程创建子进程时,父进程和子进程都从fork后的那个点开始继续执行

  • 父进程可以通过执行wait和waitpid一直阻塞到子进程结束

  • wait函数会使调用者的执行挂起,直到子进程的状态成为可用,或者调用者收到一个信号为止

exec函数

  • fork函数创建了调用进程的一份拷贝,但很多应用程序都需要子进程执行与其父进程不同的代码

  • exec函数族提供了用新的映像来覆盖调用进程的进程映像的功能

  • fork-exec配合应用的传统方式是:子进程(用exec函数)执行新程序,而父进程继续执行原来的代码

  • 六种不同形式的exec函数的区别在于命令行参数和环境变量的传递方式。它们的不同还在于是否要给出可执行文件的完整的路径名

    • execl(execl, execlp, execle)函数用一个显式的序列来传递命令行参数,如果在编译时就知道命令行参数的数目,这些函数是很有用的
    • execv(execv, execvp, execve)函数将命令行参数放在一个参数数组中传递
  • exec函数将一个新的可执行文件拷贝到进程映像中去。程序的文本,变量,栈和堆都被重写了

  • 除非原始进程调用了execle, execve,否则新进程就继承环境(也就是说,继承了环境变量列表及其相关的值)

后台进程与守护进程

  • 命令解释程序是一个用来提示命令,从标准输入中读取命令,创建子进程来执行命令并等待子进程执行完毕的一个命令解释程序。

  • 当标准输入和标准输出来自于一个终端类型的设备时,用户可以通过输入中断字符来终止一个正在执行的命令

    • 中断字符是可以设置的,但很多系统都假定中断字符的默认值为Ctrl-C
  • 大多数命令解释程序将一个以&结束的行解释为应该由后台进程执行的命令

  • 命令解释程序创建了一个后台进程时,它在发出提示符并接受其他的命令之前不用等待进程的结束。而且,从键盘键入的Ctrl-C也不能终止后台进程

  • 守护进程(daemon),是一个通常能够无限期运行的后台进程

  • UNIX操作系统依靠很多守护进程来执行例行的任务

临界区

  • 每个进程中对,应该一次只被一个进程使用的资源,进行访问的那部分代码都被称为临界区(critical section)

  • 带有临界区的程序必须要注意不能违反互斥(mutual execlusion)的原则

  • 提供互斥的一种办法是使用锁机制

  • 为了减少内部交互的复杂性,有些操作系统使用了面向对象(object-oriented)的设计。

  • 共享的表和其他资源都被封装成对象,这些对象都带有规定的很明确的访问函数。访问这样一个表的唯一的方法就是使用这些函数,这些函数都内建了恰当的互斥

  • 在分布式系统中,对象接口都使用消息

  • 从表面上看,面向对象的方法与守护进程类似,但从结构上看,这些方式可能会有很大的不同。

    • 守护进程并不一定要封装资源。它们可以以一种不受控的方式来争夺共享的数据结构
    • 好的面向对象设计保证了数据结构是被封装的,并且只能通过精心控制的接口对其进行访问。
    • 守护进程可以用面向对象的设计来实现,但并不一定非要这样实现。

UNIX I/O

  • UNIX通过文件描述符来实现统一的设备接口,这种统一的接口允许为终端,磁盘,磁带,音频甚至网络通信使用相同的I/O调用

设备术语

  • 外围设备(peripheral device)是指计算机系统访问的硬件。

    • 常见的外围设备包括磁盘,磁带,CD-ROM,显示器,键盘,打印机,鼠标和网络接口
  • 用户程序对这些设备的控制和I/O操作是通过对被称为设备驱动程序(device driver)的操作系统模块所进行的系统调用来实现的。

    • 设备驱动程序将设备操作的细节隐藏起来,并保护设备以免其受到未授权的使用
  • 有些操作系统为它所支持的每种类型的设备都提供了特定的系统调用,这就要求系统程序员掌握一组复杂的设备控制调用

  • UNIX为大多数设备提供了标准的访问接口,这就极大地简化了提供给程序员的设备接口

  • UNIX对设备的标准访问接口是通过5个函数来实现的:open, close, read, write, ioctl

  • 所有的设备都用文件来表示,这些文件被称为特殊文件(special file),存放在目录/dev

  • 因此,磁盘文件和其他设备都用统一的方式来命名和访问

    • 正常文件(regular file)只是磁盘上一个普通的数据文件
    • 块特殊文件(block special file)表示特性和磁盘类似的设备。磁盘驱动程序以块或组块的形式从块特殊设备中传送信息,而且这些设备通常都具有从设备的任何地方检索块的能力
    • 字符特殊文件(character special file)表示特性与终端类似的设备。这些设备看起来表示的是一串必须按顺序访问的字节流

读和写

  • UNIX通过read和write函数提供了对文件和其他设备的顺序访问

    • read函数试图从用fildes表示的文件或设备中取出nbyte字节,并将其放入用户变量buf中去。
  • 文件描述符,表示了打开的文件或设备,可以将文件描述符想象成进程文件描述符表的索引

  • 文件描述符表,在进程的用户区中,提供了对相关文件或设备的系统信息的访问

  • 从命令解释程序中执行一个程序时,程序的启动伴随着三个与文件描述符STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO相关的打开的流

    • STDIN_FILENO, STDOUT_FILENO分别为标准输入和标准输出。默认情况下,这两个流通常对应于键盘输入和显示器输出
    • 程序应该为错误消息使用标准错误设备STDERR_FILENO,且永远也不应该将其关闭
  • readblock(), r_write()

打开和关闭文件

  • open函数将一个文件描述符(程序中使用的句柄)与一个文件或物理设备关联起来

  • 每个文件都有三个与之相关的类:用户(或所有者),组和所有其他人(其他的人)

  • 可能的权限或者特权有读(r),写(w)和执行(x)

  • 分别独立地为用户,组和其他人指定这些特权

select函数

  • 对来自不同源端的I/O的处理是一个很重要的问题,它可能以多种不同的形式出现

  • 保持阻塞状态,直到一组条件中至少有一个条件为真为止,这种方法被称为或同步(OR synchronization)

  • 描述的情况中的条件是:描述符上的“输入可用(input available”

  • 监视多个文件描述符的一种方法是为每个描述符分别使用一个独立的进程

  • 用独立的进程来监视两个文件描述符可能很有用,但是这两个进程都有独立的地址空间,因此它们之间的交互很困难

  • select()调用提供了一种在单个进程中监视多个文件描述符的办法

  • 它可以对三种可能的状况进行监视:

    • 可以无阻塞地进行的读操作
    • 可以无阻塞地进行的写操作
    • 有挂起的错误情况的文件描述符

poll函数

  • poll函数与select类似,但它是用文件描述符而不是条件的类型来组织信息的

  • 也就是说,一个文件描述符的可能事件都存储在struct pollfd中

  • 与之相反,select用事件的类型来组织信息,而读,写和错误情况都有独立的描述符掩码

  • poll函数有三个参数:fds, nfds, timeout

    • fds, 是一个struct polldf数组,用来表示文件描述符的监视信息
    • nfds,给出了要监视的描述符的数目
    • timeout,是一个用毫秒表示的事件,是poll在返回前没有接收事件时应该等待的时间
      • 如果timeout的值为-1, poll就永远都不会超时
      • 如果整数值为32个比特,那么最大的超时周期大约为30分钟
  • 返回值:

    • 如果超时,poll函数返回0
    • 如果成功,poll返回拥有事件的描述符的数目
    • 如果不成功,poll返回-1并设置errno

文件表示

  • 在C程序中,文件由文件指针或文件描述符来指定

  • ISO C的标准I/O库函数(fopen, fscanf, fprintf, fread, fwrite, fclose and so on)使用文件指针

  • UNIX的I/O函数(open, read, write, close, ioctl)使用文件描述符

  • 文件指针和文件描述符提供了用来执行独立于设备的输入和输出的逻辑标识,这些逻辑标识被称为句柄(handle)

  • 代表标准输入,标准输出和标准错误的文件指针的符号名分别为stdin, stdout, stderr,这些符号名定义在stdio.h中。

  • 代表标准输入,标准输出和标准错误的文件描述符的符号名分别为STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO,这些符号名定义在unistd.h

库函数和系统调用之间的区别

  • POSIX标准不区分库函数和系统调用。

  • 传统上,库函数是一个普通的函数,通常因为它有用,得到广泛的应用或者是C这样的规范的一部分,而被放在一个被称为库的函数集合中。

  • 系统调用是对操作系统发出的服务请求。它包含了对操作系统的自陷(trap),通常还包含上下文切换

  • 系统调用与特定的操作系统相关。

  • 很多的库函数,例如read和write,实际上都是系统调用的外套(jacket)。

  • 也就是说,它们以恰当的,与系统相关的形式重新设置参数的格式,然后调用底层的系统调用来执行实际的操作

文件描述符

  • open函数将文件或物理设备与程序中使用的逻辑句柄相关联。用字符串(例如/home/user/my.data)来指定文件或物理设备。
  • 句柄是一个整数,可以将其理解为进程特定的文件描述符表(file descriptor table)的索引
  • 对进程中每个打开的文件,文件描述符表都包含一个相应条目,文件描述符表是进程用户区的一部分,但是除非通过使用文件描述符的函数,否则程序无法对其进行访问

文件指针和缓冲

  • ISO C标准的I/O库用文件指针而不是文件描述符作为I/O的句柄

  • 文件指针(file pointer)指向进程用户区中的一个被称为FILE结构的数据结构

  • FILE结构包括一个缓冲区和一个文件描述符值

  • 文件描述符值是文件描述符表中条目的索引,实际上就是通过这个文件描述符表将文件输出到磁盘的

  • 从某种意义上说,文件指针就是句柄的句柄

  • 磁盘文件通常都是完全缓冲(fully buffered)的,这就意味着,fprint实际上没有将消息写入磁盘,而是将这些字节写入了FILE结构的一个缓冲区里。

  • 缓冲区填满时,I/O子系统就会用文件描述符来调用write。

  • 文件执行fprintf的时刻和实际进行写操作的时刻之间的时延可能会造成意外的结果,尤其是在程序崩溃的时候。

  • 系统崩溃时,有时会丢失缓冲的数据,因此,甚至会出现:程序看起来是正常结束了,但它的磁盘输出确实不完整的

  • 程序怎样才能避免缓冲对它的影响?

  • fflush调用会强制写出FILE结构中缓冲的任何内容

  • 程序也可以调用setvbuf来禁止缓冲

  • 终端I/O的工作方式不同,与终端相关的文件是行缓冲(line buffered)的,而不是完全缓冲的(标准错误除外,它在默认情况下是不缓冲的)

  • 对输出来说,行缓冲意味着在缓冲区被填满或遇到一个新行符号之前,行不会被写出

过滤器和重定向

  • UNIX提供了大量作为过滤器而编写的工具

  • 过滤器(filter)从标准输入中读入,执行一个转换,然后将结果输出到标准输出中去

  • 过滤器将它们的错误消息写入标准错误

  • 过滤器所有的参数都作为命令行参数传送

  • 输入数据不应该有首部或尾部,而且过滤器也不应该要求与用户进行任何交互

  • 实用的UNIX过滤器的例子包括:head, tail, more, sort, grep, awk

  • cat命令将一个文件名列表作为命令参数,一个接一个地读其中的每个文件,并将每个文件的内容会送到标准输出中去

  • 但是,如果没有指定输入文件,cat就会从标准输入获取它的输入,并将结果写入标准输出。在这种情况下,cat表现得像过滤器一样

  • 文件描述符是那个进程的文件描述符表的一个索引。文件描述符表中的每个条目都指向系统文件表中的一个条目,该条目是在文件被打开时创建的。

  • 程序可以对文件描述符表的条目进行修改,使其指向系统文件表中的另一个条目。这种动作叫做重定向(redirection)

  • 大多数命令解释程序豆浆命令行中的大于字符(>)解释成对标准输出的重定向,而小于字符(<)解释成对标准输入的重定向

  • dup2函数有两个参数:fildes, fildes2

文件控制

  • fcntl()函数是一个通用函数,可用来检索和修改与打开的文件描述符相关联的标志符
  • fcntl()参数fildes指定了描述符,参数cmd指定了操作

文件和目录

  • 操作系统将原始存储设备以文件系统的形式组织起来,这样应用程序就可以用高级操作,而不是低级的设备调用来访问信息

  • UNIX文件系统是树形的,节点表示文件,弧线表示包含关系

  • UNIX目录项将文件名与文件所在位置关联起来。

  • 这些目录项可以直接指向一个包含文件位置信息的结构(硬链接)

  • 也可以通过符号链接间接地指向文件所在位置

    • 符号链接是将一个文件名关联到另一个文件名的文件

UNIX文件系统导航

  • 文件系统(file system),是文件和属性的集合,其中的属性包括位置和名字等

  • 应用程序不用指定文件在磁盘上的物理位置,而只需指定文件名和偏移量。操作系统通过它的文件系统将其翻译为物理文件的位置

  • 目录,是一个包含了目录项(directory entry)的文件,目录项将文件名与文件在磁盘上的物理位置关联起来

  • 绝对路径名(absolute pathname)或全称路径名(fully qualified pathname),指定了文件系统树中从根到文件自身的路径上所有的节点

  • 程序不一定总要用全称路径名来制定文件。

  • 任何时候,每个进程都有一个用来作路径名解析的相关目录,这个目录被称作当前工作目录(current working directory)

  • pathconf函数,是允许程序以一种与平台无关的方式来确定系统和运行期极限的函数族中的一个

目录访问

  • 目录不能用普通的open, close, read函数来访问。

  • 相反,访问目录需要使用特定的函数,相应的函数名以dir结束:opendir, closedir, readdir

  • opendir()函数,为一个目录流提供了DIR*类型的句柄,该流的当前位置就在目录的第一项上

  • 定义在dirent.h中的DIR类型,表示的是一个目录流(directory stream)

  • 目录流是一个特定目录中所有目录项组成的一个有序序列。目录流中的条目不一定是按文件名的字母顺序排列的

  • readdir()函数,是通过返回dirp所指向的目录流中的连续条目来读取目录的

  • readdir()在每次调用之后都将流转移到下一个位置上去

  • closedir()函数,关闭一个目录流,而rewinddir()函数把目录流重新定位在起始处

  • 每个函数都有一个参数dirp,这个参数对应于打开的目录流

访问文件状态信息

  • fstat()函数,用打开的文件描述符来访问文件

  • stat()lstat()函数通过名字来访问文件

    • 它们都有两个参数,参数path指定了需要返回状态的文件或符号链接的名字
    • 如果path不对应于符号连接,也就是文件,它们就返回相同的结果
    • 当path是一个符号链接时
      • lstat()函数返回与链接有关的信息,
      • stat()函数返回与链接所指向的文件有关的信息
    • 参数buff指向一个用户提供的缓冲区,这些函数都将信息存储在这个缓冲区中

确定文件的类型

  • 文件模式成员st_mode指定了文件的访问权限和文件的类型
  • POSIX规定用不同宏来测试不同文件类型的st_mode成员
    • S_ISBLK(m) – 块特殊文件
    • S_ISCHR(m) – 字符特殊文件
    • S_ISDIR(m) – 目录
    • S_ISFIFO(m) – 管道或FIFO特殊文件
    • S_ISLNK(m) – 字符链接
    • S_ISREG(m) – 正常文件
    • S_ISSOCK(m) – 套接字
    • S_TYPEISMQ(buf) – 消息队列
    • S_TYPEISSEM(buf) – 信号量
    • S_TYPEISSHM(buf) – 共享的内存对象
  • 测试文件类型的POSIX宏
  • m的类型为mode_t, buf的值是一个指向struct stat结构的指针

UNIX文件系统的实现

  • 磁盘格式化将物理磁盘分隔成被称为分区(partition)的区域
  • 每个分区都可以有自己的文件系统与之相关联

UNIX文件的实现

  • 目录项中包含一个文件名以及对一个定长结构的引用,这个定长结构被称作索引节点(inode)

  • 索引节点中包括了与文件长度,文件位置,文件所有者,创建时间,最后访问时间,最后修改时间,权限等有关的信息

  • 块(block),可以表示不同的含义(甚至在UNIX系统内部也是如此)。

  • 在这里,块通常是8K字节,一个块中的字节数通常是2的幂

  • POSIX不要求系统真的采用索引节点来表示它的文件

  • struct stat的成员ino_tst_ino,现在被称为文件序列号(file serial number),而不是索引节点号(inode number)

硬链接和符号连接

  • UNIX目录中有两种类型的链接:链接和符号链接

  • 链接,是指一个目录项,有时也被称为硬链接(hard link),目录项可以将文件名与文件位置关联起来

  • 符号链接(symbolic link),有时也称为软连接(soft link),是指存储了一个字符串的文件。如果在路径名解析过程中遇到了这个字符串,就用它来修改路径名

(硬)链接的创建与删除

  • link()函数为path1指定的已存在文件创建一个新的目录项,这个文件位于path2指定的目录中

  • 函数原型:int link(const char *path1, const char *path2)

  • 头文件:#include <unistd.h>

  • unlink()函数删除了path指定的目录项

  • 函数原型:int unlink(const char *path);

  • 头文件:#include <unistd.h>

符号链接的创建与删除

  • 符号链接,是一个包含了另一个文件或目录名字的文件。

  • 引用符号链接的名字会使操作系统去定位对应于那个链接的索引节点

  • 操作系统假设相应的索引节点的数据块中包含另一个路径名。然后,操作系统对那个路径名的目录项进行定位,并继续跟踪这个链接,直到最终遇到一个硬链接和一个真正的文件为止。

  • 如果系统过了一段时间还没有找到真正的文件,它就放弃并返回ELOOP错误

  • symlink()函数创建一个符号链接,参数path1包含了将成为链接的内容的字符串,path2给出了链接的路径名。

    • 换句话来说,path2就是新创建的链接,而新链接指向path1
  • 函数原型:int symlink(const char *path1, const char *path2);

  • 头文件:#include <unistd.h>

du

  • UNIX的命令解释程序命令duPOSIX:UP扩展的一部分
  • 命令用来显示树的子目录的大小,这个树的根位于它的命令行参数指定的目录中
  • 如果调用时未带目录,du工具就使用当前的工作目录

UNIX特殊文件

  • 管道和FIFO是这些特殊文件的两种重要实例。
  • 它们是进程间通信机制,这些机制使得运行在同一个系统中的进程可以共享信息,从而互相合作

管道

  • 对那些需要相互配合来解决问题的进程来说,通信是一种必备的能力

  • 最简单的UNIX进程间通信机制是管道,管道由特殊文件来表示

  • pipe()函数创建了一个通信缓冲区,程序可以通过文件描述符fildes[0]fildes[1]来访问这个缓冲区

  • 写入fildes[1]的数据可以按照先进先出的顺序从fildes[0]中读出

  • 使用管道的单个进程并没有很大的用处。

  • 通常父进程会用管道来与它的子进程进行通信

流水线

  • 垂直线|表示一个管道
  • 管道就像进程间的缓冲区一样,允许进程以不同的速度读和写。read和write的阻塞性本质有效地同步了进程

FIFO

  • 没有进程打开管道时,管道就消失了,从这个意义上来说,管道是临时的

  • POSIX用特殊文件来表示FIFO或命名管道(named pipe),这些特殊文件在所有的进程都将其关闭之后仍然存在

  • FIFO像普通文件一样,有名字和访问权限,而且会出现在ls列出的目录列表中

  • 任何一个具有恰当权限的进程都可以访问FIFO

  • 可以在命令解释程序中执行mkfifo命令

  • 或者从程序中调用mkfifo()函数来创建FIFO

  • 删除时使用rm命令或unlink()函数

管道与客户机-服务器模型

  • 客户机-服务器模型是进程间交互的一种标准模式。
  • 一个被称为客户机(client)的进程,向另一个称为服务器(server)的进程请求服务
  • 两种类型的客户机-服务器通信方式:
    • 简单-请求(simple-request)方式,客户机以单向传输的方式向服务器发送消息
    • 请求-应答(request-reply)方式,客户机发送一个请求,服务器就会发送一个应答

终端控制

  • 很多特殊文件表示的设备都具有与平台相关的特性,这使得标准化工作变得很困难

  • 命令stty用来报告或设置终端I/O的特性

  • 执行时不带任何参数或者执行时带有选项-a-g时,命令将与当前终端有关的信息输出到标准输出中去

  • tcgetattr()函数用来检索出与终端相关的属性,终端由打开的文件描述符fildes引用

  • 原型:int tcgetattr(int fildes, struct termics *termios_p);

  • passwordnosigs()函数使用ctermid()函数确定的控制终端,而没有使用标准输入

  • 控制终端通常类似于/dev/tty

规范与非规范的输入处理

  • 一种常见的误解是:

    • 键盘和显示器是以某种方式连在一起的,因此你键入的所有内容都自动地出现在显示器上
  • 实际上,键盘和显示器是互相独立的设备,它们分别与运行在计算机上的终端设备驱动程序进行通信

  • 通信驱动程序从键盘接收字节,然后根据这些设备的设置指定的方式对其进行缓冲和编辑

  • 处理终端输入的一种常见的方法是每次处理一行输入,这种处理方法被称为规范模式(canonical mode)

  • 行是一个由新行(NL),文件结束(EOF)或行结束(EOL)定界的字节序列

  • 在非规范模式(noncanonical mode)下,输入没有被组装成行

  • 非规范输入处理有两个控制参数:MIN和TIME

    • MIN,用来控制read返回之前应该收集的最少的字节数
    • TIME,指一个粒度为0.1秒的定时器,这个定时器用于突发传输的超时处理

音频设备

  • 音频设备(麦克风,扬声器)是由特殊文件表示的外围设备的一种实例
  • 在很多系统中,这些设备的设备表示都是/dev/audio

路障

  • 路障(barrier),是协同操作的进程使用的一种同步结构
  • 使用了路障之后,在所有的进程都到达一个特定点之前,进程一直保持阻塞

项目:令牌环(180)

环的形成

  • 在程序代码中引用这些文件描述符时,一定要使用STDIN_FILENO, STDOUT_FILENO
  • 文件描述符表的条目,就是指向系统文件表条目的指针
  • 例如
    • 条目[4]中的pipe写表示:指向系统文件表中pipe的写条目的指针
    • 条目[0]中的标准输入表示:指向系统文件中对应于默认标准输入设备的条目的指针(默认的标准输入通常是键盘)

匿名环中的领导者选举

  • 分布式算法的规范,将执行算法的实体称为进程(process)或处理器(processor)

  • 这种算法通常用有限状态机的形式来说明底层的处理器模型。

  • 处理器模型,是按照状态转移是如何被驱动的(同步)以及处理器是否被标记来划分的

  • 在同步处理器模型(synchronous processor model)中,处理器按照锁步执行,而状态转移是时钟驱动的

  • 在异步处理器模型(asynchronous processor model)中,状态转移是消息驱动的

    • 在通信链路上接收消息会出发处理器状态的改变。处理器可能会向它的邻居发送消息执行某些计算,或者因为有消息输入而暂停。
    • 在处理器键任意给定的链接上,消息都按照发送的顺序到达。消息会有一个有限的,但不可预测的传输时延
  • 处理器模型还必须说明单独的处理器是否被标记,或者它们是否是不可辨别的。

  • 在一个匿名系统(anonymous system)中,处理器不具有可以辨别的特性。

  • 总的来说,包含了匿名处理器或进程的系统的算法,比包含有标记的处理器或进程的系统的相应算法更复杂一些

图像过滤

  • 过滤器,是对图像进行的一种变换

  • 根据变换类型的不同,过滤可能会消除噪声,增强细节或者模糊图像的特征。

  • 针对一副由n x n字符数组表示的灰度数字图像进行过滤

  • 通用的空间过滤器(spatial filter),用一个原始像素及其邻居的函数来替换这幅图像中的每个像素值。

  • 过滤器算法用一个掩码来说明对计算做出贡献的邻居范围。因为函数是掩码中像素的加权和,所以这个特定的掩码,表示了一个线性过滤器(linear filter)

  • 与之相反,非线性过滤器不能写成掩码中像素的线性组合。使用邻近像素的中位数就是非线性滤波器的一个例子

  • 块的计算

    • 并行处理中另外一个重要的问题就是问题的粒度,以及如何将这种粒度映射为进程数

信号

  • 重点强调了信号处理并发方面的问题

信号的基本概念

  • 信号(signal), 是向进程发送的软件通知,通知进程有事件发生

  • 引发信号的事件发生时,信号就被生成(generate)了。

  • 进程根据信号采取行动时,信号就被传递(deliver)了。

  • 信号的寿命(lifetime)就是,信号的生成和传递之间的时间间隔

  • 已经生成但还未被传递的信号被称为挂起(pending)的信号,

  • 在信号生成和信号传递之间可能会有相当长的时间。传递信号时,进程必须在处理器上运行

  • 如果在传递信号时,进程执行了信号处理程序(signal handler),那么进程就捕捉(catch)到了这个信号

  • 如果将进程设置为忽略(ignore)某个信号,那么在传递时那个信号就会被丢弃,不会对进程产生影响

  • 信号生成时,所采取的的行动取决于那个信号当前使用的信号处理程序和进程信号掩码(process signal mask)

  • 信号掩码中包含一个当前被阻塞信号(blocked signal)的列表

  • 阻塞一个信号很容易和忽略一个信号混淆起来

    • 被阻塞的信号不会像被忽略的信号一样丢弃
    • 如果一个挂起信号被阻塞了,那个当进程解决了对那个信号的阻塞时,信号就会被传递出去

产生信号

  • 每个信号都有一个以SIG开头的符号名。

  • 信号的名字都定义在signal.h中,任何一个使用了信号的C程序中都要包含这个文件

  • 信号的名字表示的是大于0的小整数

  • 在命令解释程序上可以用kill命令产生信号。

  • 历史上,很多信号的默认行为都是将进程终止,kill这个名字就由此而来

  • 在一个程序中调用kill()函数会向一个进程发送信号。

  • 原型:int kill(pid_t pid, int sig);

  • 函数将进程ID和一个信号码作为参数。

    • 如果参数pid大于0,kill就向那个ID表示的进程发送信号。
    • 如果pid为0,kill就像调用程序的进程组成员发送信号
    • 如果参数pid为-1, kill就向所有它有权发送信息的进程发送信号
    • 如果参数pid的值是其他负数,kill就将信号发送到组ID等于pid的进程组中去
  • 返回值

    • 成功,返回0
    • 失败,返回-1,并设置errno
  • 进程可以用raise()函数向自己发送一个信号

  • int raise(int sig);

  • 函数只有一个参数,即信号码

  • 返回值

    • 成功,返回0
    • 失败,返回一个非零的错误值,并设置errno

对信号掩码和信号集进行操作

  • 进程可以通过阻塞信号暂时地阻止信号的传递。在传递之前,被阻塞的信号不会影响进程的行为

  • 进程的信号掩码(signal mask)给出了当前被阻塞的信号的集合。信号掩码的类型为sigset_t

  • 可以用类型为sigset_t的信号集来指定对信号组的操作(例如阻塞或接触阻塞的操作)

  • 信号集由下面的五个函数来操作,每个函数的第一个参数都是一个指向sigset_t的指针

    • int sigaddset(sigset_t *set, int signo) – 负责将signo加入信号集
    • int sigdelset(sigset_t *set, int signo) – 将signo从信号集中删除
    • int sigemptyset(sigset_t *set); – 对一个sigset_t类型的信号集进行初始化,使其不包含任何信号
    • int sigfillset(sigset_t *set); – 对一个sigset_t类型的信号集进行初始化,使其包含所有的信号
    • int sigismember(const sigset_t *set, int signo); – 报告signo是否在sigset_t

捕捉与忽略信号 – sigaction

  • sigaction()函数,允许调用程序检查或指定与特定信号相关的动作

  • int sigaction(int sig, const struct sigaction *restrict act, struct sigaction *restrict oact);

  • 函数的参数sig来指定动作的信号码

  • 参数act是一个指向struct sigaction结构的指针,用来说明要采取的动作

  • 在基于POSIX的标准中,信号处理程序是一个普通函数,它返回void, 并有一个整型的参数

等待信号 – pause, sigsuspend, sigwait

  • 信号提供了一种不需要忙等(busy waiting)来等待事件的方法。

  • 忙等,是指连续地使用CPU周期来检测事件的发生。通常,程序都通过在循环中测试一个变量的值来进行这种检测。

  • 更有效的方法是,将进程挂起直到所等待的事件发生为止;这样,其他的进程就可以更有效地使用CPU了

  • POSIX中的pause(), sigsupend(), sigwait()提供了三种机制,用来挂起进程,直到信号发生为止

  • pause()函数,将调用线程挂起,直到传递了一个信号为止,这个信号的动作或者是执行用户定义的处理程序,或者是终止进程

  • 如果信号的动作是终止进程,pause就不返回。如果信号被进程捕捉,pause就会在信号处理程序返回之后返回

  • #include <unistd.h> int pause(void);

  • pause函数总是返回-1,如果被信号中断,pause就将errno设置为EINTR

  • 要用pause来等待一个待定的信号,就必须确定哪个信号会使用pause返回。这个信息并不是直接可用的,因此信号处理程序必须设置一个标志符,以便程序在pause返回之后对其进行检查

  • sigsuspend(),用sigmask指向的那个掩码来设置信号掩码,并将进程挂起,知道进程捕捉到信号为止

  • int sigsuspend(const sigset_t *sigmask);

  • 被捕捉信号的信号处理程序返回时,sigsuspend函数就返回。

  • sigwait()函数,一直阻塞直到*sigmask指定的任何一个信号被挂起为止,然后从挂起信号集中删除那个信号,并接触对它的阻塞

  • sigwait()返回时,从挂起信号集中删除的信号的个数被存储在signo指向的那个位置中

  • int sigwait(const sigset_t *restrict sigmask, int *restrict signo);

  • 如果成功,返回0。如果失败,返回-1,并设置errno

信号处理原则

  • 如果拿不准,就在程序中显式地重启库函数调用,或者使用重启库
  • 检查信号处理程序中使用的每个库函数,确保函数在异步信号安全函数的列表中
  • 仔细地分析修改外部变量的信号处理程序和访问的那个变量的其他按程序代码之间潜在的交互,阻塞信号以防出现不希望的交互
  • 适当的时候保存并回复errno

用异步I/O编程

  • 通常,在执行读操作或写操作时,进程会一直阻塞直到I/O完成为止。

  • 某些注重性能的应用程序宁愿先初始化请求,然后继续执行,这样就允许I/O操作的处理异步(asynchronously)于程序的执行

  • POSIX:AIO扩展对异步I/O的定义基于四个主要函数。

    • #include <aio.h>
    • int aio_read(struct aiocb *aiocbp); – 允许进程对一个打开的文件描述符上的读操作请求进行排队
    • int aio_write(struct aiocb *aiocbp); – 对写操作请求进行排队
    • 它们都只有一个参数 – aiocbp,它是一个指向异步I/O控制块的指针。
    • aio_read()从与aiocbp->aio_fildes相关的文件中将aiocbp->aio_bytes字节读入一个由aiocbp->aio_buf指定的缓冲区中。请求被放入队列之后,函数就返回。
    • ssize_t aio_return(struct aiocv *aiocbp); – 指定I/O操作的状态
    • int aio_error(const struct aiocb *aiocbp);

时间和定时器

  • 操作系统为进程调度,网络协议超时以及定期更新系统的统计信息等目的使用定时器
  • 应用程序通过对系统时间和定时器函数的访问来测量性能或确定事件发生的时间
  • 应用程序也可以用定时器来实现协议,控制与用户的交互

POSIX时间

  • POSIX规定系统应该记录从Epoch开始的以秒为单位的时间,每天都被精确地计为86400秒

  • Epoch,新纪元被定义为协调世界时(也称为UTC,格林尼治标准时间或GMT)的1970年1月1日,00:00(午夜)

  • POSIX基本标准只支持秒级的分辨率,并用类型time_t来表示从Epoch开始的时间,time_t类型通常都用long类型来实现

  • 程序可以通过调用time()函数来访问系统时间(从Epoch开始的秒数表示)。如果tloc不为NULL,time函数还会将时间存储在*tloc中

    • #include <time.h> time_t time(time_t *tloc);
    • 如果成功,返回从Epoch开始计算的秒数
    • 如果失败,返回-1,
  • difftime函数负责计算两个time_t类型的日历时间之间的差值,以简化包含时间的计算

  • difftime函数有两个time_t类型的参数,并返回一个double类型的值,其中包含的是第一个参数减去第二个参数得到的差值

    • #include <time.h>
    • double difftime(time_t time1, time_t time0);
  • 对于需要计算时间差值的计算来说,使用time_t类型是很方便的,但是用来打印日期就非常繁琐

  • 函数localtime()有一个参数,这个参数用来说明从Epoch开始的秒数,并返回一个结构,这个结构中带有根据本地需求调整过的时间成分(例如,日,月和年)

    • struct tm *localtime(const time_t *timer);
  • asctime()函数将localtime()返回的结构转换成字符串

    • char *asctime(const struct tm *timeptr);
  • ctime()函数的额功能等同于asctime(localtime(clock))

    • char *ctime(const time_t *clock);
    • ctime()函数用静态存储的方式来保存时间字符串,对ctime的两次调用都将字符串存储在同一个位置,因此在使用第一个值之前,第二次调用可能会将第一个值覆盖
  • gmtime()函数的额参数为从Epoch开始的描述,并返回一个结构,这个结构中带有协调时间时表示(UTC)的时间成分

    • struct tm *gmtime(const time_t *timer);
    • gmtime函数和localtime函数将时间划分成独立的字段,使得程序可以很容易地输出日期或时间的不同部分
  • ISO定义结构体struct tm中应该包含下列成员:

    • int tm_sec; int tm_min; int tm_hour; int tm_mday; int tm_mon; int tm_year; int tm_wday; int tm_yday; int tm_isdst;
  • 对于程序定时或者控制程序事件来说,用秒作为事件的尺度太粗糙了

  • POSIX:XSI扩展用struct timeval结构,以更精细的尺度来表示时间。

  • struct timeval结构包含如下成员

    • time_t tv_sec; /* 从Epoch开始的秒数*/
    • time_t tv_usec; /* 从Epoch开始的微秒数 */
  • gettimeofday函数用来获取自Epoch以来的,用秒和微妙表示的系统时间。

  • tp指向的struct timeval结构负责接收获取的时间,指针tzp必须为NULL,这个指针是由于历史原因才包含进来的

    • #include <sys/time.h>
    • int gettimeofday(struct timeval *restrict tp, void *restrict tzp);
    • 函数返回0,没有保留其他的值来指示错误

使用实时时钟

  • 时钟(clock),是一个计数器,它的值以固定间隔增加,这个固定间隔被称为时钟分辨率(clock resolution)

  • POSIX:TMR定时器扩展中包含了各种用clockid_t类型的变量表示的时钟

  • struct timespec结构用来为POSIX:TMR时钟和定时器指定时间,也用来为支持超时的POSIX线程函数指定超时值。

  • struct timespec结构至少包含下列成员

    • time_t tv_sec; /* 秒 */
    • long tv_nsec; /* 纳秒 */
  • POSIX提供了设置时钟时间的函数(clock_settime),获取时钟时间的函数(clock_gettime)和确定时钟分辨率的函数(clock_getres)

    • #include <time.h>
    • int clock_getres(clockid_t clock_id, struct timespec *res);
    • int clock_gettime(clockid_t clock_id, struct timespec *tp);
    • int clock_settime(clockid_t clock_id, const struct timespec *tp);
  • 每个函数都有两个参数:

    • 用来标识特定时钟的clockid_t
    • 一个指向struct timespec结构的指针
  • 返回值

    • 成功,返回0
    • 失败,返回-1,并设置errno
  • time函数测量的是实际时间(real time),有时又称为实耗时间或挂钟时间。

  • 在多道程序设计环境中,很多进程共享CPU,因此实际时间并不能精确地测量出执行时间

  • 进程的虚拟时间(virtual time)是进程在运行(running)状态耗费的时间总量。执行时间通常都用虚拟时间而不用挂钟时间来表示

  • times函数用时间账单信息来填充它的参数buffer指向的struct tms结构

    • #include <sys/times.h>
    • clock_t times(struct tms *buffer);
  • 返回值

    • 成功,返回用时钟滴答计数表示的实际耗费的时间,这个时间是从过去的任意一点开始计算的,比如可以从系统或进程的起始时间开始计算
    • 失败,返回-1,并设置errno
  • struct tms结构至少包含以下成员

    • clock_t tms_utime; /*进程的用户CPU时间*/
    • clock_t tms_stime; /* 由进程使用的系统CPU时间 */
    • clock_t tms_cutime;/* 进程及其已终止的子进程的用户CPU时间*/
    • clock_t tms_cstime; /* 由进程及其已终止的子进程使用的系统CPU时间 */

睡眠函数(247)

  • 自愿地阻塞一段特定时间的进程被称为在睡眠(sleep)。

  • sleep函数使调用线程挂起,直到经过了特定的秒数,或者调用线程捕捉到信号的时候为止

    • unsigned sleep(unsigned seconds);
  • 返回值:

    • 如果请求的时间已经到了,函数就返回0
    • 如果被中断了,sleep函数就返回还没有睡眠的时间值
  • sleep函数与SIGALRM之间有交互作用,所以应该避免在同一个进程中同时使用它们

  • nanosleep函数会使调用线程的执行挂起,直到rqtp指定的时间间隔到期或线程收到一个信号为止

    • #include <time.h>
    • int nanosleep(const struct timespec *rqtp, struct timespec *tmtp);
  • 如果函数被信号中断,且rmtp不为NULL,则rmtp指定的位置上包含的就是剩余时间,这样函数可以被重启动

  • 系统时钟CLOCK_REALTIME决定了rqtp的分辨率

  • 返回值

    • 成功,返回0
    • 失败,返回-1,并设置errno
  • nanosleep函数试图取代usleep,现在任免都认为usleep已经过时了

    • 与usleep相比,nanosleep最主要的有点是,它不会影响包括SIGALRM在内的任何信号的使用

间隔定时器

  • 定时器会在经过一段特定的时间之后产生一个通知

  • 时钟采用增量的方式来记录所经过的时间,定时器与之不同,它通常是减少它的值,并在值为零时产生一个信号

  • 计算机系统通常只有少量的硬件间隔定时器,操作系统通过使用这些硬件定时器可以实现多个软件定时器

  • 分时操作系统也可以用间隔定时器来进行进程调度。

  • 操作系统调度一个进程时,它就为一个被称为调度时间片(scheduling quantum)的时间间隔启动了一个间隔定时器

  • 如果这个定时器到期,而进程还在执行,调度程序就将程序转移到一个就绪队列中去,这样其他进程就可以执行了

  • 在多处理其系统中,每个处理器都需要一个这样的间隔定时器

  • getitimer函数,获取当前的时间间隔

  • setitimer函数,启动和终止用户的间隔定时器

    • #include <sys/time.h>
    • int getitimer(int which, struct itimerval *value);
    • int setitimer(int which, const struct itimerval *restrict value, struct itimerval *restrict ovalue);
  • 参数which用来指定定时器(即ITIMER_REAL,ITIMER_VIRTUAL, ITIMER_PROF)

  • 返回值:

    • 函数成功,返回0
    • 失败,返回-1,并设置errno

实时信号

  • 在基本的POSIX标准中,信号处理程序是一个带有单个整型参数的函数,这个整数代表了所产生信号的信号码

  • POSIX:XSI扩展和POSIX:RTS实时信号扩展都对信号处理能力进行了扩展,包含了信号排队和向信号处理程序传递信息的能力

  • 标准对sigaction结构进行了扩展,以允许信号处理程序使用额外的参数。

  • 如果定义了_POSIX_REALTIME_SIGNALS,实现就可以支持实时信号

  • sigqueue函数向ID为pid的进程发送带有值value的信号signo

  • 如果signo为零,就会执行错误检测,但是不会发送信号

    • #include <signal.h>
    • int sigqueue(pid_t pid, int signo, const union sigval value);
  • 返回值:

    • 成功,返回0
    • 失败,返回-1,并设置errno

POSIX:TMR间隔定时器

  • POSIX:XSI扩展的间隔定时器功能分配给每个进程少量的,固定数目的定时器

  • POSIX:TMR扩展采用了一种替换方法,在这种方法中只有少量的时钟,一个进程可以为每个时钟创建很多独立的定时器

  • POSIX:TMR定时器是基于struct itimerspec结构的,这个结构包含下列成员

    • struct timespec it_interval; /* 定时器周期 */
    • struct timespec it_value; /* 定时器到期值 */
  • 对POSIX:XSI定时器来说,it_interval是定时器到期后,用来重置定时器的时间

  • 成员it_value装载的是定时器到期之前剩余的时间

  • 进程可以通过调用timer_create创建特定的定时器。

  • 定时器是每个进程自己的,不是在fork时继承的

    • #include <signal.h>
    • #include <time.h>
    • int timer_create(clockid_t clock_id, struct sigevent *restrict evp, timer_t *restrict timerid);
  • 参数:

    • timer_create()的参数clock_id说明定时器是基于哪个时钟的,
    • *timerid装载的是被创建的定时器的ID
    • 参数evp指定了定时器到期时要产生的异步通知,如果为NULL,那么定时器就会产生默认的信号
  • 返回值:

    • 成功,返回0
    • 失败,返回-1,并设置errno
  • 代码段

    • timer_t timerid;
    • if(timer_create(CLOCK_REALTIME, NULL, &timerid) == -1) {perror("Failed to create a new timer)};
  • timer_delete()函数删除了ID为timerid的POSIX:TMR定时器

    • #include <time.h>
    • int timer_delete(timer_t timerid);
  • 返回值:

    • 成功,返回0
    • 失败,返回-1,并设置errno
  • 操纵每个进程一个的POSIX:TMR定时器

    • 函数timer_settime负责启动或停止timer_create创建的定时器。参数flags说明定时器使用的是相对时间还是绝对时间
      • 相对时间与POSIX:XSI定时器使用的策略类似,
      • 绝对时间的精确度更高,并可以对定时器漂移进行控制
    • 函数timer_settime用value指向的值来设置timerid指定的定时器。如果ovalue不为NULL,timer_settime就将定时器以前的值放在ovalue指定的位置上。
  • 有可能一个定时器到期了,而同一个定时器上一次到期时产生的信号还处于挂起状态,在这种情况下,其中的一个信号可能会丢失,这就称作定时器超限(timer overrun)

  • 程序可以通过调用imer_getoverrun()来确定一个特定的定时器出现这种超限的次数

    • 定时器超限,只会发生在同一个定时器产生的信号上
    • 由多个定时器,甚至是那些使用相同的时钟和信号的定时器,所产生的信号都会排队而不会丢失

定时器漂移,超限和绝对时间

  • 一个与POSIX:TMR定时器和POSIX:XSI定时器有关的问题就是,根据相对时间来设置这些定时器的方式

  • 假设,设置了一个间隔2秒的周期性中断,当定时器到期后,系统自动地用另一个2秒的间隔来重启定时器。

  • 假设从定时器到期,到定时器被重新设置之间的等待时间为5纳秒,那么定时器的实际周期为2.000005秒,在1000此中断之后,定时器会偏离5ms

  • 这种不准确型就被称为定时器漂移(timer drift)

  • 处理漂移问题的一种方法是记录定时器实际上什么时候应该到期,并调整每次设置定时器的值。

  • 这种方法使用绝对时间(absolute time),而不是相对时间(relative time)来设置定时器

破解命令解释程序

  • 命令解释程序(shell),是一个用来对命令行进行解释的程序

  • 换句话来说,命令解释程序从标准输入读入命令行,并执行对应于输入行的命令

  • 在最简单的情况下,命令解释程序读入一条命令并创建一个子进程来执行命令。然后父进程要在读入另一条命令之前,等待这个子进程执行完毕。

  • 实际的命令解释程序,要负责处理进程流水线和重定向,以及前台进程组,后台进程组和信号

重定向

  • POSIX通过文件描述符用独立于设备的方式来处理I/O。
  • 通过open或pipe这样的调用获得一个打开的文件描述符之后,程序就可以用从调用中返回的句柄来执行read或write了
  • 重定向允许程序将一个已经打开了的句柄重新分配,去标识另一个文件

进程组,会话和控制终端

  • 进程组(process group),是为信号传递这样的目的建立的进程集合。

  • 每个进程都有一个进程组ID(process group ID)来表示它所属的进程组

  • kill命令和kill函数都将负的进程ID值作为进程组ID来处理,并向相应进程组的每个成员发送一个信号

  • 进程组组长(process group leader),是一个进程,它的进程ID值与进程组ID值相同。

  • 只要进程组里还有进程,进程组就一直存在。如果组长死了或者加入了另一个组,进程组可能就没有组长了

  • 进程可以用setpgid来改变它的进程组。

  • setpdig函数将进程pid的进程组ID设置为进程组ID gpid。如果pid为0,它就使用调用进程的进程ID。如果pgid为0,pid指定的进程就成为一个组长

    • #include <unistd.h>
    • int setpgid(pid_t pid, pid_t gpid);
  • 返回值:

    • 成功,返回0
    • 失败,返回-1,并设置errno
  • getpgrp函数返回调用进程的进程组ID

    • #include <unistd.h>
    • pid_t getpgrp(void);
  • 为了实现信号的透明传递,POSIX使用了会话和控制终端

  • 会话(session),是为作业控制建立的一个进程组的集合。

  • 会话的创建者就被称为会话组长(session leader)

  • 用会话组长的进程ID来标识会话。每个进程都属于一个会话,会话是它们从父进程那里继承来的

  • 每个会话都应该有一个与之相关的控制终端(controlling terminal)

  • 命令解释程序用它的会话的控制终端来与用户交互

  • 一个特定的控制终端只与一个会话有关,一个会话中可能有多个进程组,但在任一给定的时候,只有一个进程组可以从控制终端接收输入并向控制终端发送输出。

  • 这个特定的进程组被称为前台进程控制组(foreground process group)或前台作业(foreground job)

  • 会话中其余的进程组被称为后台进程组(background process group)或后台作业(background job)

  • 作业控制的主要目的是改变在前台的进程组

  • 用ctermid函数来获取控制终端的名字

    • #include <stdio.h>
    • char *ctermid(char *s);
  • 函数返回一个指向字符串的指针,这个字符串对应于当前进程的控制终端的路径名

  • 如果s是一个NULL指针,这个字符串可能位于静态生成的区域中,

  • 如果s不为NULl,它应该指向一个至少有L_Ctermid字节的字符数组

  • ctermid函数将一个表示控制终端的字符串拷贝到那个数组中去

  • 如果失败,返回空字符串

  • 进程可以通过调用setsid来创建一个以它自己为组长的新会话

    • #include <unistd.h>
    • pid_t setsid(void);
  • 返回值:

    • 成功,返回新的进程组ID值
    • 失败,返回-1, 并设置errno
  • 进程可以通过调用getsid来发现会话ID

  • 函数getsid将一个进程组ID–pid,作为参数,并返回pid指定的那个进程的会话组长的进程组ID

    • #include <unistd.h>
    • pid_t getsid(pid_t pid);
  • 返回值:

    • 成功,返回一个进程组ID
    • 失败,返回-1,并设置errno

作业控制

  • 如果命令解释器程序允许用户将前台进程组移到后台去,并允许用户将进程组从后台移到前台,那么这个命令解释程序就是有作业控制(job control)功能的

  • 作业控制包括对控制终端前台进程组进行修改

  • tcgetpgrp函数,用来返回一个特定控制终端的前台进程组的进程组ID

  • 要获得控制终端的打开的文件描述符,就要打开ctermid函数中获得的路径名

    • #include <unistd.h>
    • pid_t tcgetpgrp(int fildes);
  • 返回值

    • 成功,返回与终端相关的前台进程组的进程组ID
    • 失败,返回-1,并设置errno
  • tcsetpgrp函数将与fildes相关的控制终端的前台进程组设置为pgid_id

    • #include <unistd.h>
    • int tcsetpgrp(int fildes, pid_t pgid_id);
  • 返回值:

    • 成功,返回0
    • 失败,返回-1,并设置errno

并发

  • 实现并行的一种方法是多个进程通过共享内存或消息传递来进行协作和同步,另一种方法是在单个地址空间中使用多个执行线程

线程管理

  • 线程包中通常包含了用于线程创建和线程销毁,调度,强制互斥和条件等待的函数

  • 典型的线程包中还包括一个运行系统来对线程进行透明的管理,也就是说,用户是不知道运行系统的存在的

  • 线程被创建时,运行系统分配数据结构来状态线程ID,栈和程序计数器值。

  • 线程的内部数据结构中可能还包括调度和使用信息。

  • 一个进程的各个线程共享那个进行的整个地址空间。它们可以修改全局变量,访问打开的文件描述符,并用其他的方式互相配合或互相干扰

  • 因为所有的线程函数都以pthread开始,所以有时POSIX线程被称为pthreads

  • POSIX线程管理函数

    • pthread_cancel – 终止另一个线程
    • pthread_create – 创建一个线程
      • int pthread_create(pthread_t *restrict thread, const pthread_attr_t *restrict attr, void*(*start_routine)(void *), void *restrict arg);
      • pthread_create函数创建了一个线程。与有些线程工具,例如Java编程语言提供的那些线程工具不同,POSIX的pthread_create会自动使线程成为可运行的,而不需要一个单独的启动操作
      • 参数
        • thread,指向新创建的线程ID
        • attr, 表示一个封装了线程的各种属性的属性对象。如果为NULL,新线程就具有默认的属性
        • start_routine,是线程开始执行的时候调用的函数的名字。
          • start_routine有一个由arg指定的参数,这个参数是一个指向void的指针
          • start_routine返回一个指向void的指针,这个返回值被pthread_join当做退出状态来处理
      • 返回值:
        • 成功,返回0
        • 失败,返回一个非零的错误码
    • pthread_detach – 设置线程以释放资源
    • pthread_equal – 测试两个线程ID是否相等
      • pthread_t pthread_equal(pthread_t t1, pthread_t t2);
      • 如果t1等于t2,pthread_equal返回一个非零值;如果不相等,返回0
    • pthread_exit – 退出线程,而不退出进程
    • pthread_kill – 向线程发送一个信号
    • pthread_join – 等待一个线程
    • pthread_self – 找出自己的线程ID
      • pthread_t pthread_self(void);
  • 返回值:

    • 成功,大多数线程函数都返回0
    • 失败,大多数线程函数都会返回非零的错误码,它们不设置errno,因此调用程序不能用perror来报告错误

分离和连接

  • 除非是一个分离线程,否则在线程退出时,它是不会释放它的资源的

  • pthread_detach函数将线程分离,它设置线程的内部选项来说明线程退出后,线程的存储空间可以被重新收回

  • 分离线程退出时不会报告它们的状态。没有分离的线程是可接合的,而且在另一个线程为它们调用pthread_join或者整个进程退出之前,这些线程不会释放它们所有的资源

  • pthread_detach函数有一个参数thread,这个参数是要分离的线程的线程ID

    • int pthread_detach(pthread_t thread);
  • 返回值:

    • 成功,返回0,
    • 失败,返回一个非零的错误码
  • 在另一个线程用终止线程的ID值作为第一个参数调用pthread_join之前,未分离线程的资源是不会被释放的

    • int pthread_join(pthread_t thread, void **value_ptr);
  • pthread_join函数将调用线程挂起,直到第一个参数指定的目标线程终止为止

  • 参数value_ptr为指向返回值的指针提供了一个位置,这个返回值是由目标线程传递给pthread_exit或return的

  • 如果value_ptr为NULL,调用程序就不会对目标线程的返回状态进行检索了

  • 返回值:

    • 成功,返回0
    • 失败,返回一个非零的错误码
  • 如果线程执行pthread_join(pthread_self());,会发生什么情况

    • 假设线程是可接合的(不是已分离的),这条语句就会造成死锁
    • 有些实现可以检测到死锁,并迫使pthread_join带着错误EDEADLK返回
    • 但是,POSIX:THR扩展并不要求进行这种检测

退出和取消

  • 进程的终止可以通过直接调用exit,执行main中的return,或者通过进程的某个其他线程调用exit来实现

  • 在任何一种情况下,所有的线程都会终止

  • 如果主线程在创建了其他线程之后没有工作可做,它就应该阻塞到所有线程都结束为止,或者应该调用pthread_exit(NULL)

  • 调用exit会使整个进程终止

  • 调用pthread_exit只会使调用线程终止

  • void pthread_exit(void *value_ptr);

  • 在顶层执行return的线程隐式地调用了pthread_exit,调用时将返回值(一个指针)当做pthread_exit的参数使用。

  • 如果进程的最后一个线程调用了pthread_exit,进程会带着状态返回值0退出

  • 对一个成功的pthread_join来说,value_ptr的值是可用的。

  • 但是,pthread_exit中的value_ptr必须指向线程退出后仍然存在的数据,因此线程不应该为value_ptr使用指向自动局部数据的指针

  • 线程可以通过取消机制,迫使其他线程返回。线程可以调用pthread_cancel来请求取消另一个线程。

  • 结果由目标线程的类型和取消状态决定

    • int pthread_cancel(pthread_t thread);
  • 参数:

    • thread, 要取消的目标线程的线程ID
  • 返回值:

    • 成功,返回0
    • 失败,返回一个非零的错误码
  • 线程收到一个取消请求时会发生什么情况取决于它的状态和类型。

  • 如果线程处于PTHREAD_CANCEL_ENABLE状态,它就接收取消请求

  • 另一方面,如果线程处于PTHREAD_CANCEL_DISABLE状态,取消请求就会被保持在挂起状态。

  • 默认情况下,线程处于PTHREAD_CANCEL_ENABLE状态

  • pthread_setcancelstate函数用来改变调用线程的取消状态

    • int pthread_setcancelstat(int state, int *oldstate);
  • 参数

    • state,说明要设置的新状态
    • oldstate, 指向一个整数的指针,这个整数中装载了以前的状态
  • 返回值:

    • 成功,返回0
    • 失败,返回一个非零的错误码
  • 作为一个通用的原则,改变了其取消状态或类型的函数应该在返回之前回复它们的值

  • pthread_setcanceltype函数,根据它的type参数指定的值来修改线程的取消类型

线程安全

  • 线程中隐藏的一个问题是它们可能会调用非线程安全的库函数,这样可能会产生错误的结果。

  • 如果多个线程能够同时执行函数的多个活动请求而不会相互干扰,那么这个函数就是线程安全的(thread-safe)

  • 在传统的UNIX实现中,errno是一个全局外部变量,当系统函数产生一个错误时,就会设置errno。

  • 对多线程来说,这种实现方式是无法工作的,在大多数线程实现中,errno是一个用来返回线程的特定信息的宏

  • 本质上来说,每个线程都有一份私有的errno拷贝。

  • 主线程不能直接访问一个接合线程的errno,因此如果需要的话,必须通过pthread_join的最后一个参数来返回这些信息

用户线程和内核线程

  • 用户级线程(user-level thread)和内核级线程(kernel-level thread)是两种传统的线程控制模式

  • 用户级线程,通常都运行在一个现存的操作系统之上。

  • 这些线程对内核来说是不可见的,它们之间还会竞争分配给它们的封装进程的资源。

  • 线程由一个线程运行系统来调度,这个系统是进程代码的一部分

  • 带有用户级线程的程序通常会连接到一个特殊的库上去,这个库中的每个库函数都用外套(jacket)包装起来

  • POSIX引入了一个线程调度竞争范围(thread-scheduling contention scope)的概念,这个概念赋予了程序员一些控制权,使他们可以控制怎样将内核实体映射为线程

线程的属性

  • POSIX将栈的大小和调度策略这样的特征封装到一个pthread_attr_t类型的对象中去,用面向对象的方式表示和设置特征

  • 属性对象只是在线程创建的时候会对线程产生映像。可以先创建一个属性对象,然后再将栈的大小和调度策略这样的特征与属性对象关联起来

  • 可以通过向pthread_create传递相同的线程属性对象来创建多个具有相同特征的线程

  • pthread_attr_init用默认值对一个线程属性对象进行初始化

  • pthread_attr_destroy函数将属性对象的值设为无效的

    • int pthread_attr_destroy(pthread_attr *attr);
    • int pthread_attr_init(pthread_attr *attr);
  • 返回值:

    • 成功,返回0
    • 失败,返回一个非零的错误码
  • pthread_attr_getdetachstate函数用来查看一个属性对象的状态

  • pthread_attr_setdetachstate函数用来设置一个属性对象的状态

线程栈

  • 线程有一个栈,用户可以设置栈的位置和大小,如果必须将线程栈放在一个特定的内存区中,这就是一个有用的特征

  • 要为线程定义栈的布局和大小,就必须先用特定的栈属性来创建一个属性对象,然后用这个属性对象来调用pthread_create

  • pthread_attr_getstack函数用来查看栈的参数

  • pthread_attr_setstack函数用来设置一个属性对象的栈参数

    • int pthread_attr_getstack(const pthread_attr_t *restrict attr, void **restrict stackaddr, size_t *restrict stacksize);
    • int pthread_attr_setstack(pthread_attr_t *attr, void *stackaddr, size_t stacksize);
  • 参数

    • 每个函数的参数attr都是一个指向属性对象的指针
    • pthread_attr_setstack函数将栈的地址和栈的大小作为额外的参数
    • pthread_attr_getstack函数则将指向这些条目的指针当做参数
  • 返回值:

    • 成功,返回0
    • 失败,返回一个非零的错误码
  • 如果用户还没有设置stackaddr,POSIX还提供了检查栈溢出或者为栈溢出设置警戒的函数

  • pthread_attr_getguardsize函数用来查看警戒参数

  • pthread_attr_setguardsize函数在一个属性对象中设置了用来控制栈溢出的警戒参数

    • int pthread_attr_setguardsize(const pthread_attr_t *restrict attr, size_t *restrict guardsize);
    • int pthread_attr_setguardsize(pthread_attr_t *attr, size_t guardsize);
  • 返回值:

    • 成功,返回0
    • 失败,返回一个非零的错误码

线程调度

  • 对象的竞争范围(contention space)控制了线程是在进程内部还是在系统级竞争调度资源

  • pthread_attr_getscope用来查看竞争范围

  • pthread_attr_setscope用来设置一个属性对象的竞争范围

    • int pthread_attr_getscope(const pthread_attr_t *restrict attr, int *restrict conttentionspace);
    • int pthread_attr_setscope(pthread_attr_t *attr, int contentionscope);
  • 参数:

    • attr是一个指向属性对象的指针
    • contentionscope对应于要为pthread_attr_setscope设置的值,以及一个指向要从pthread_attr_getscope获得的值的指针
  • 返回值:

    • 成功,返回0
    • 失败,返回一个非零的错误码
  • POSIX允许线程用不同的方式继承调度策略

  • pthread_attr_getinheritsched函数负责查看调度继承策略

  • pthread_attr_setinheritsched函数负责为一个属性对象设置调度继承策略

    • int pthread_attr_getinheritsched(const pthread_attr_t *restrict attr, int *restrict inheritsched);
    • int pthread_attr_setinheritsched(pthread_attr_t *attr, int inheritsched);
  • 返回值:

    • 成功,返回0
    • 失败,返回一个非零的错误码

线程同步

  • POSIX支持用于短期锁定的互斥锁以及可以等待无期限事件的条件变量

互斥锁

  • 互斥量,是一种特殊的变量,它可以处于锁定(locked)状态,也可以处于解锁(unlocked)状态

  • 如果互斥量是锁定的,就有一个特定的线程持有(hold)或拥有(own)这个互斥量

  • 如果没有线程持有这个互斥量,就说这个互斥量处于解锁(unlocked),空闲(free)或可用(available)的状态

  • 互斥量还有一个等待持有该互斥量的线程队列。

  • 互斥量的等待队列中的线程获得互斥量的顺序由线程调度策略确定,但POSIX没有要求实现任何特定的策略

  • 互斥量(mutex)或互斥锁(mutex lock),是最简单也是最有效的线程同步机制

  • 程序用互斥锁来保护临界区,以获得对资源的排他性访问权

  • 互斥量只能被段时间地持有。

  • 互斥函数不是线程取消点,也不能被信号中断

  • 除非进程终止了,(从信号处理程序中)用pthread_exit终止了线程,或者异步取消了线程(通常不用这种方法),否则,等待互斥量的线程不能被逻辑地中断

  • 出现等待输入这样的持续时间不确定的情况下,用条件变量来进行同步

  • POSIX使用 pthread_mutex_t 类型的变量来表示互斥锁

  • 程序在用 pthread_mutex_t 变量进行同步之前,通常必须对其进行初始化

    • 对静态分配的 pthread_mutex_t 变量来说,只要将PTHREAD_MUTEX_INITIALIZER赋给变量就可以了
    • 对动态分配或没有默认互斥属性的互斥变量来说,要调用pthread_mutex_init来执行初始化工作
  • pthread_mutex_init的参数mutex是一个指向要初始化的互斥量的指针

    • int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
  • 参数

    • attr, 互斥属性对象,传入NULL,使用默认属性
  • 返回值:

    • 成功,返回0
    • 失败,返回非零的错误码
  • pthread_mutex_destroy函数销毁了它的参数所引用的互斥量

    • int pthread_mutex_destroy(pthread_mutex_t *mutex);
  • 参数

    • mutex,是一个指向要销毁的互斥量指针
  • 返回值:

    • 成功,返回0
    • 失败,返回非零的错误码
  • pthread_mutex_lock函数会一直阻塞到互斥量可用为止

  • pthread_mutex_trylock函数,通常会立即返回

  • pthread_mutex_unlock函数用来释放指定的互斥量

    • int pthread_mutex_lock(pthread_mutex_t *mutex);
    • int pthread_mutex_trylock(pthread_mutex_t *mutex);
    • int pthread_mutex_unlock(pthread_mutex_t *mutex);
  • 这三个函数都只有一个参数mutex,这个参数是一个指向互斥量的指针

  • 返回值:

    • 成功,返回0
    • 失败,返回非零的错误码
  • 因为互斥锁必须能被所有需要同步的线程访问,所以,它们通常会以全局变量的形式出现(内部或外部链接)

  • 线程化程序中大多数共享的数据结构都必须由同步机制保护,以确保能得到正确的结果

最多一次和至少一次的执行

  • 单次初始化的概念非常重要,POSIX甚至还提供了一个pthread_once函数来确保这个语义的实现
    • int pthread_once(pthread_once_t *once_control, void (*init_routine)(void));
    • pthread_once_t once_control = PTHREAD_ONCE_INIT;
  • 必须用PTHREAD_ONCE_INIT对参数once_control进行静态初始化
  • 返回值:
    • 成功,返回0
    • 失败,返回非零的错误码

条件变量

  • 考虑一个使进程一直等待,直到某个任意的条件被满足了为止的问题。

  • 具体来说,假设有两个变量x和y被多个线程共享。希望一个线程一直等到x和y相等为止。

  • 典型的不正确的忙等解决方法是:

    • while (x != y);
  • 断言x == y为真所用的正确的非忙等策略

    • 锁定互斥量
    • 测试条件x == y
    • 如果为真,解除对互斥量的锁定,并退出循环
    • 如果为假,将线程挂起,并解除对互斥量的锁定
  • 应用程序通过使用pthread_mutex_lock和pthread_mutex_unlock这样定义的很明确的系统库函数来操纵互斥队列。

  • 这些函数还不足以实现(用一种简单的方式)这里要求的队列操作。

  • 需要一种新的数据类型,一种与等待x == y这样的任意条件为真的进程队列相关的数据类型。这样的数据类型被称为条件变量(condition variable)

  • 函数pthread_cond_wait将一个条件变量和一个互斥量作为参数,它原子地挂起调用线程并解除对互斥量的锁定

  • 可以认为它将线程放入了一个线程队列,队列中的线程都在等待条件发生变化的通知

  • 线程收到通知时,函数会带着重新获得的互斥量返回

  • 在继续执行之前,线程必须再次对条件进行测试

  • 如何用POSIX条件变量v和互斥量m来等待条件x == y

    • pthread_mutex_lock(&m);
    • while (x != y)
      • pthread_cond_wait(&v, &m);
    • pthread_mutex_unlock(&m);
  • 函数pthread_cond_wait只能由拥有互斥量的线程调用,当函数返回时,线程就再次拥有了互斥量

条件变量的使用和sigsuspend的使用

  • 两个概念是相似的

  • 阻塞信号并对条件进行测试。因为在信号被阻塞的时候,信号处理程序不能访问全局变量sigreceived,所以阻塞信号与锁定互斥是类似的

  • 当sigsuspend返回时,信号再次被阻塞。

  • 线程用条件变量锁定互斥量来保护它的临界区并对条件进行测试。

  • pthread_cond_wait原子地释放了互斥量并将进行挂起。当pthread_cond_wait返回时,线程就再次拥有了互斥量

创建和销毁条件变量

  • POSIX用pthread_cond_t类型的变量来表示条件变量

  • 程序必须在使用该变量之前对其进行初始化

  • 对那些静态分配的,带有默认属性的pthread_cond_t变量来说,简单地将PTHREAD_COND_INITIALIZE赋给变量就可以完成初始化

  • 对那些动态分配的或不具有默认属性的变量来说,就要调用pthread_cond_init来执行初始化

    • int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
    • pthread_cond_t cond = PTHREAD_COND_INITIALIZE;
  • 返回值:

    • 成功,返回0
    • 失败,返回非零的错误码
  • 函数pthread_cond_destroy销毁了它的参数cond引用的条件变量

    • int pthread_cond_destroy(pthread_cond_t *cond);
  • 返回值:

    • 成功,返回0
    • 失败,返回非零的错误码

等待并通知条件变量

  • 条件变量是与断言或条件的测试一同调用的,条件变量的名字就是从这个事实引申出来的

  • 通常,线程会对一个断言进行测试,如果测试失败,就调用pthread_cond_wait

  • 函数pthread_cond_timewait可以用来等待一段有限的时间

    • int pthread_cond_timewait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex, const struct timespec *restrict abstime);
    • int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
  • 这些函数的第一个参数是cond,这是一个指向条件变量的指针

  • 第二个参数是mutex,这是一个指向互斥连的遏制真

    • 线程在调用之前应该拥有这个互斥量
    • 当线程被放置在条件变量等待队列中时,等待操作会使线程释放这个互斥量
  • pthread_cond_timewait函数的第三个参数是一个指向返回时间的指针,这个值表示的是绝对时间,而不是时间间隔

  • 返回值:

    • 成功,返回0
    • 失败,返回非零的错误码
  • 当另一个线程修改了可能会使断言成真的变量时,它应该唤醒一个或多个在等待断言成真的线程

  • pthread_cond_signal函数至少解除了对一个阻塞在cond指向的条件变量上的线程的阻塞

  • pthread_cond_broadcast函数解除了所有阻塞在cond指向的条件变量上的线程的阻塞

    • int pthread_cond_broadcast(pthread_cond_t *cond);
    • int pthread_cond_signal(pthread_cond_t *cond);
  • 返回值:

    • 成功,返回0
    • 失败,返回非零的错误码

条件变量规则

  • 条件变量没有被链接到特定的断言上去,pthread_cond_wait可能会因为假唤醒而返回

  • 使用条件变量时要遵守的规则

    • 在测试断言之前获得互斥量
    • 因为返回可能是由某些不相关的事件或无法使断言成真的pthread_cond_signal引起的,所以要在从pthread_cond_wait返回值后重新对断言进行测试
    • 在修改断言中出现的任一变量之前,要获得互斥量
    • 仅仅在较短的时间段中持有互斥量 – 通常是在测试断言或者修改共享变量的时候
    • 显示地(用pthread_mutex_unlock)或隐式地(用pthread_cond_wait)释放互斥量

信号处理与线程

  • 进程中所有线程都共享进程的信号处理程序,但每个线程都有它自己的信号掩码

  • 由于线程的操作可以异步于信号,所以线程与信号的交互会比较复杂

  • 三种类型的信号及其相应的传递方法

    • 异步 – 传递给某些解除了对该信号的阻塞的线程
    • 同步 – 传递给引发(该信号)的线程
    • 定向的 – 传递给标识了的线程(pthread_kill)
  • SIGFPE(浮点异常)这样的信号就是同步于引发它们的线程(也就是说,它们通常在线程执行的相同位置上产生)

  • pthread_kill函数要求产生信号码为sig的信号,并将其传送到thread指定的线程中去

    • int pthread_kill(pthread_t thread, int sig);
  • 返回值:

    • 成功,返回0
    • 失败,返回非零的错误码
  • 使线程将它自己和整个进程都杀死

    • if (pthread_kill(pthread_self(), SIGKILL))
      • fprintf(stderr, "Failed to commit suicide \n");
  • 一种常见的概念混淆是假定pthread_kill总是会使进程终止的,但实际上并不是这样的,pthread_kill仅仅为线程产生一个信号

  • 为线程屏蔽信号。虽然信号处理程序是进程范围的,但是每个线程都有它自己的信号掩码

  • 线程可以用pthread_sigmask函数来检查或设置它的信号掩码

    • int pthread_sigmask(int how, const sigset_t *restrict set, sigset_t *restrict oset);
  • 参数how和set指出了修改信号掩码的方式

    • how的值SIG_SETMASK会使线程的信号掩码被set取代
    • 也就是说,现在线程阻塞set中所有的信号,但不阻塞任何其他信号
    • how的值SIG_BLOCK使线程阻塞set中的其他信号(添加到线程当前的信号掩码中)
    • how的值SIG_UNBLOCK从线程当前的信号掩码中将set中当前被阻塞的信号删除(不再阻塞)
  • 如果参数oset不为NULL,函数就将*oset设置为线程的前一个信号掩码

  • 返回值:

    • 成功,返回0
    • 失败,返回非零的错误码
  • 为信号处理指定专用线程

  • 信号处理程序是进程范围的,与在单线程的进程中一样,可以用sigaction调用来安装它们

  • 在线程化程序中,进程范围的信号处理程序和线程特定的信号掩码之间的区别是很重要的

  • 在多线程的进程中进行信号处理的一种推荐策略是:为信号处理使用特定的线程

  • 主线程在创建线程之前阻塞了所有的信号。信号掩码是从创建线程中继承的。

  • 这样,所有的线程都将信号阻塞了。然后,专门用来处理信号的线程对那个信号执行sigwait

  • 或者,线程可以用pthread_sigmask来接触对信号的阻塞

读者和写者

  • 读-写者问题指的是这样的一种情况:

    • 在这种情况下,允许对资源进行两种类型的访问(读和写)
    • 一种类型的访问必须确保是排他的(比如,写操作),但是另一种类型的访问可以是共享的(比如,读操作)
  • 处理读-写者同步的两种常见的策略被称为强读者同步(strong reader synchronization)和强写者同步(strong writer synchronization)

    • 在强读者同步中,总是给读者以优先权,只要写者当前没有进行写操作,读者就可以获得访问权
    • 在强写者同步中,通常将优先权交给写者,二将读者延迟到所有等待或活动的写者都完成了为止
  • POSIX提供了读-写锁:如果写者没有持有锁,就允许多个读者获得这个锁

  • POSIX声明,当前写者阻塞在锁上时,就由实现来决定是否允许读者获取锁

  • POSIX读-写锁由pthread_rwlock_t类型的变量表示。

  • 程序在用pthread_rwlock_t变量进行同步之前,必须调用pthread_rwlock_init来初始化这个变量

    • int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr);
  • 参数rwlock是一个指向读-写锁的指针

  • 将NULL传递给pthread_rwlockattr_t,以便用默认属性来初始化读-写锁。否则,就要使用与线程属性对象类似的方法,县创建,然后再初始化读-写锁属性对象

  • 返回值:

    • 成功,返回0
    • 失败,返回非零的错误码
  • pthread_rwlock_destroy函数销毁了它的参数引用的读-写锁

    • int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
  • 参数rwlock是一个指向读-写锁的指针

  • 返回值:

    • 成功,返回0
    • 失败,返回非零的错误码
  • pthread_rwlock_rdlock和pthread_rwlock_tryrdlock函数允许线程为读操作获取一个读-写锁

  • pthread_rwlock_wrlock和pthread_rwlock_trywrlock函数允许线程为写操作获取一个读-写锁

  • pthread_rwlock_ldlock和pthread_rwlock_wrlock函数一直保持阻塞,到有锁可用为止

  • pthread_rwlock_tryldlock和pthread_rwlock_trywrlock函数则会立即返回

  • pthread_rwlock_unlock函数会将锁释放掉

    • int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
    • int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
    • int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
    • int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);
    • int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
  • 这些函数要求将一个指向锁的指针作为参数传递

  • 返回值:

    • 成功,返回0
    • 失败,返回非零的错误码

strerror_r的实现

  • 通常,主线程是唯一一个打印错误消息的线程

  • strerror为非线程安全的函数

  • perror_r和sterror_r函数既是线程安全的,又是异步信号安全的

临界区和信号量

  • 管理共享资源的程序必须以互斥的方式来执行被称为临界区的代码段

处理临界区

  • 共享设备,被称为排他性资源(exclusive resources),因为它们一次只能由一个进程访问

  • 进程必须以互斥(mutually exclusive)的方式来执行访问这些共享资源的代码

  • 临界区(critical section),是必须以互斥的方式执行的代码段,也就是说,在临界区的范围内,只能有一个活动的执行线程

  • 临界区问题(critical section problem),是指安全,公平和对称的方式来执行临界区代码的问题

  • 可以将带有同步临界区的代码组织成不同的部分

    • 入口区(entry section),包含了请求对共享变量或其他资源进行修改的代码
    • 临界区(critical section),包括访问共享资源或执行不可重入代码的代码
    • 退出区(exit section),提供的对访问权的显示释放是必须的
    • 剩余区(remainder section),释放了访问权之后,线程可以执行的其他代码
  • 好的临界区问题解决方案要求公平和排他性访问(exclusive access)。

    • 试图进入临界区的执行线程不应该被无限期地推迟
    • 线程也应该有进展
    • 如果当前没有线程在临界区,就应该允许一个等待线程进入

信号量

  • 信号量是一个整型变量,它带有两个原子操作wait和signal

    • wait还可以被称为down, P或lock
    • signal还可以称为up, V, unlock或post
  • 在POSIX:SEM的术语中,wait和signal操作分别被称为信号量锁定(semaphore lock)和信号量解锁(semaphore unlock)

  • 我们可以把信号量想成一个整数值和一个等待signal操作的进程列表

  • wait和signal操作必须是原子的。

  • 原子操作(atomic operation)是这样一种操作,一旦将其启动了,就要以一种逻辑上不可分割的方式来完成(也就是说,不会与任何其他相关的指令产生交错)

POSIX:SEM无名信号量

  • POSIX:SEM信号量是一个sem_t类型的变量,有相关的原子操作来对它的值进行初始化,增量和减量操作

  • POSIX:SEM信号量扩展定义了两种类型的信号量:命名信号量和无名信号量

  • 如果一个实现在unistd.h中定义了_POSIX_SEMAPHORES,那么这个实现就支持POSIX:SEM信号量

  • 无名信号量和命名信号量之间的区别类似于普通管道和命名管道(FIFO)之间的区别

    • #include <semaphore.h>
    • sem_t sem;
  • 必须在使用POSIX:SEM信号量之前对其进行初始化

  • sem_init函数将sem引用的无名信号量初始化为value

    • int sem_init(sem_t *sem, int pshared, unsigned value);
  • 参数value不能为负

  • pshared等于0,说明信号量只能由初始化这个信号量的进程中的线程使用

  • 如果pshared非0,任何可以访问sem的进程就都可以使用这个信号量

  • 返回值:

    • 成功,sem_init就将sem初始化
    • (没有定义返回值)
  • sem_destroy函数销毁了一个参数sem引用的,已经初始化了的无名信号量

    • int sem_destroy(sem_t *sem);
  • 返回值:

    • 成功,返回0
    • 失败,返回-1,并设置errno
  • sem_post对信号量的值进行增量操作

    • int sem_post(sem_t *sem);
  • 返回值:

    • 成功,返回0
    • 失败,返回-1,并设置errno
  • sem_wait函数实现了经典的信号量wait操作

    • int sem_wait(sem_t *sem);
  • 如果信号量的值为0,调用进程就一直阻塞直到一个相应的sem_post调用解除了对它的阻塞为止,或者直到它被信号中断为止

  • sem_trywait与sem_wait类似,只是在试图对一个为零的信号量进行减量操作时,它不阻塞,而是返回-1并将errno置为EAGAIN

    • int sem_trywait(sem_t *sem);
  • 返回值:

    • 成功,返回0
    • 失败,返回-1,并设置errno
  • sem_getvalue函数允许用户检测一个命名信号量或者无名信号量的值

    • int sem_getvalue(sem_t *restrict sem, int *strict sval);
  • 返回值:

    • 成功,返回0
    • 失败,返回-1,并设置errno

POSIX:SEM命名信号量

  • 可以用POSIX:SEM命名信号量来同步那些不共享内存的进程

  • 命名信号量和文件一样,有一个名字,一个用户ID,一个组ID的权限

  • 信号量的名字是一个遵守路径名构造规则的字符串

  • sem_open函数建立了命名信号量和sem_t值之间的连接

    • sem_t *sem_open(const char *name, int oflag, ...);
  • 参数name是一个用名字来标识信号量的字符串,这个名字可以对应于文件系统中实际的对象,也可以不对应

  • 参数oflag用来确定是创建信号量,还是仅仅由函数对其进行访问

  • 返回值:

    • 成功,返回信号量的地址
    • 失败,返回SEM_FAILED,并设置errno
  • sem_close函数关闭命名信号量,但是这样做并不能将信号量从系统中删除

    • int sem_close(sem_t *sem);
  • 参数sem,用来指定要关闭的信号量

  • 返回值:

    • 成功,返回0
    • 失败,返回-1,并设置errno
  • sem_unlink函数与文件或FIFO的unlink函数类似,在所有的进程关闭了命名信号量之后将命名信号量从系统中删除

  • 当进程显式地调用sem_close, _exit, exit, exec或执行从main的返回时,就会出现关闭操作

    • int sem_unlink(const char *name);
  • 参数name,指向要删除的信号量的指针

  • 返回值:

    • 成功,返回0
    • 失败,返回-1,并设置errno

POSIX IPC

  • 共享内存,消息队列和信号量集等经典的UNIX进程间通信(IPC)机制都在POSIX:XSI扩展中进行了标准化
  • 这些机制允许不相关的进程通过一种合理有效的途径来交换信息,这些机制用键(key)来标识,创建或访问相应的额实体

POSIX:XSI进程间通信

  • POSIX进程间通信(interprocess communication, IPC),是POSIX:XSI扩展的一部分,起源于UNIX System V进程间通信

  • IPC中包含消息队列,信号量集和共享内存,为同一个系统中的进程提供了共享信息的机制

    • 消息队列
      • msgctl – 控制
      • msgget – 创建或访问
      • msgrcv – 接收消息
      • msgsnd – 发送消息
    • 信号量
      • semctl – 控制
      • semget – 创建或访问
      • semop – 执行操作(等待或发送)
    • 共享内存
      • shmat – 将内存附加到进程中去
      • shmctl – 控制
      • shmdt – 将内存从进程中分离开
      • shmget – 创建并初始化或访问
  • POSIX:XSI用一个唯一的整数来标识每个IPC对象,这个整数大于或等于零,从对象的获取函数中返回这个整数的方式与open函数返回表示文件描述符的整数的方式类似

  • 创建或访问一个IPC对象时,必须指定一个键来说明要创建或访问的特定对象。

  • 有三种方式来选择一个键

    • 由系统来选择一个键(IPC_PRIVATE)
    • 直接选一个键
    • 通过调用ftok请求系统从指定的路径中生成一个键
  • ftok函数允许独立的进程根据一个已知的路径名导出相同的键。

    • #include <sys/ipc.h>
    • key_t ftok(const char *path, int id);
  • 对应于路径名的文件必须存在,并且必须能够被那些想访问IPC对象的进程访问

  • path和id的组合唯一地标识了IPC对象。

  • 参数id允许几个相同类型的IPC对象从一个路径名中生成键值

  • 返回值:

    • 成功,返回一个键
    • 失败,返回-1,并设置errno
  • 从命令解释程序中访问POSIX:XSI IPC资源

    • 命令解释程序和实用程序的POSIX:XSI扩展定义了检查和删除IPC资源的命令解释程序命令,这时POSIX:SEM信号量所没有的一项很方便的特性
    • ipcs命令,显示了与POSIX:XSI进程间通信资源有关的信息
      • ipcs [-qms] [-a | -bcopt]
    • 小写的-q, -s, -m选项用对象ID分别指定要删除的消息队列,信号量集或共享内存段
    • 大写的选项使用初始的创建键(creation key)

POSIX:XSI信号量集

  • POSIX:XSI信号量由一个信号量元素(semaphore element)数组组成

  • 信号量元素与Dijsktra提出的标准的整数信号量类似,但两者并不完全相同。

  • 进程可以在单个调用中对整个集合执行操作

  • 将POSIX:XSI信号量称为信号量集(semaphore set),与POSIX:SEM信号量区分开

  • 每个信号量元素中至少包含下列信息

    • 一个表示信号量元素值的非负整数(semval)
    • 最后一个操纵信号量元素的进程的进程ID(sempid)
    • 等待信号量元素值增加的进程的数量(semncnt)
    • 等待信号量元素值变为零的进程的数量(semzcnt)
  • 信号量主要的数据结构是semid_ds,它是在sys/sem.h中定义的

  • 每个信号量元素都有两个与之相关的队列:

    • 一个等待信号量值变为0的进程队列
    • 一个等待信号量值增加的进程对量
  • 信号量元素操作允许进程阻塞,知道信号量元素值为0或者它增加到一个大于零的特定值为止

  • 信号量集的创建

    • semget

(393) 暂时不看

面向连接的通信

  • 服务,是指由服务器代表客户机执行的动作

  • 客户机-服务器模型出现在计算机系统的很多层面

  • 例如:

    • 在面向对象的程序中,一个对象去调用另一个对象的方法,就被称为对象的客户(client of the object)
    • 在系统层,管理诸如打印机之类的资源的守护进程就是系统用户(客户)的服务器
    • 在因特网中,浏览器是向Web服务器请求资源的客户机进程
  • 客户机-服务器模型的关键要素

    • 由客户,而不是服务提供者,发起动作
    • 服务器被动地等待来自客户机的请求
    • 客户机和服务器通过一条通信信道连接起来,它们通过通信端点来访问这个通信信道
  • 面对不可预料的客户机行为时,服务器要能够健全地处理多个同时发出的客户机请求。

  • 在客户机-服务器交互动作的过程中,捕捉错误并采取适当行动的重要性

  • 服务器要长时间的运行,并且必须能够释放分配给独立的客户机请求的所有资源

通信信道

  • 通信信道(communication channel),是信息的逻辑通道,通信的参与者通过通信端点对其进行访问

  • 信道可以是共享的或私有的,单工的或双工的。双工信道可以是对称的或不对称的

  • 信道和底层的物理管道有所区别,物理管道可以支持多种类型的信道

  • 在面向对象的编程中,客户机通过调用一个方法来和对象进行通信

  • 命名管道有一个相关的路径名,执行命令mkfifo时,系统会在文件系统目录中创建一个对应于这个路径名的条目

  • 文件系统提供了底层的管道。进程通过调用open来创建通信端点,并通过文件描述符来访问这些端点

  • 命名管道可用于短的用户请求

  • 当请求很长或服务器必须做出响应时,命名管道会面临一些困难。

    • 如果服务器只是简单地为响应打开另一个命名管道,就不能保证各个客户机一定能读到发送给它们的相应
    • 如果服务器为每个相应打开一个唯一的管道,那么客户机和服务器就必须事先对命名约定进行协商
    • 另外,命名管道具有持久性,除非管道所有者显示地将其删除,否则它们始终存在。当交互各方不再存在时,一个通用的通信机制应该释放它的资源
  • TCP(Transmission Control Protocol, 传输控制协议), 是面向连接的协议,它在可能并不可靠的通道上,为通信提供可靠的信道

  • 面向连接(Connectionoriented),是指起始端(客户机)先建立一个与目的端(服务器)的连接,之后双方就都可以发送和接收消息了

  • 在起始端和目的端之间,TCP通过一种被称为三次握手(three-way handshake)的消息交换方式建立连接

  • TCP通过接收端确认和重传来实现可靠通信。TCP还提供流量控制,这样发送端就不会用大量的信息将接收端淹没了

  • 幸运的是,操作系统的网络子系统实现了TCP,所以协议交换的细节在进程级是不可见的

  • 如果网络出现了故障,进程会在通信端点上检测出错误。

  • 由于对服务的请求中包含可见的通信过程,从这个意义上讲,无连接和面向连接协议都是低层次的。

  • 程序员要明确地知道服务器的位置,而且必须显示地命名要访问的特定的服务器

  • 在网络环境中命名服务器和服务是个很难的问题。

  • 标识服务器的一种显而易见的办法就是利用它的进程ID和主机ID。

  • 但是,操作系统一般是根据进程的创建时间按时间顺序分配进程ID的,因此客户机不可能事先知道主机上一个特定服务器进程的进程号

  • 指定一个服务最常用的方法是,使用主机地址(IP地址)和一个被称作端口号的整数。

  • 采用这种方式时,服务器要监视一个或多个通信信道,这些通信信道与事先为特定服务指定的端口号相关联

  • 客户机为通信显式地指定一个主机地址和一个端口号(有相关的主机名访问IP地址的库函数调用)

  • 本章的重点是与由主机地址和端口号指定的服务器进行的面向连接的通信,通信采用了TCP/IP和流套接字

面向连接的服务器策略

  • 一旦服务器收到一个请求,它就可以用很多不同的策略来处理这个请求

  • 串行服务器(serial server),要在完全地处理好一个请求之后才能接受其他的请求

  • 串行服务器一次只处理一个请求,因此处理像文件传输这样长寿命请求的繁忙的服务器不能采用串行服务器策略

  • 什么是僵子进程?

    • 僵进程(zombie),是一种已经执行完毕但没有被其父进程等待的进程
    • 僵进程没有释放它所有的资源,所以系统最终会耗尽一些关键的资源,例如:内存或进程ID
  • 线程化服务器(threaded server),服务器在它自己的进程空间创建一个线程,而不是创建子进程来处理客户机请求。

通用因特网通信接口

  • UICI(Universal Internet Communication Interface,通用因特网通信接口)库,为UNIX中的面向连接通信提供了简化接口

  • UICI,不是任何UNIX标准的一部分。

  • 接口是由作者设计的,在隐藏了底层网络协议细节的同时,对网络通信的实质进行了抽象。

  • UICI是公开的,使用UICI的程序中应该包含uici.h头文件

  • 使用套接字时,服务器创建一个通信端点(一个套接字)并将其与一个知名端口相关联(将套接字绑定到端口上)

  • 在等待客户机请求之前,服务器要将套接字设置为被动的,这样套接字就可以接收客户机请求了(将套接字设置为监听状态)

  • 一旦在这个端点检测到客户机连接请求,服务器就为此客户机的私有双工通信创建一个新的通信端点

  • 客户机和服务器通过文件描述符进行读和写操作来实现对通信端点的访问。

  • 当通信完成时,两端都关闭文件描述符,释放与此通信信道相关的资源

  • 客户机-服务器通信中使用的UICI调用的典型顺序

    • 服务器创建一个通信端点(u_open)并等待客户机发送请求(u_accept)
    • u_accept函数返回一个私有通信文件描述符
    • 客户机为服务器的通信创建一个通信端点(u_connect)
  • 一旦它们之间建立了连接,客户机和服务器就可以在网络上用普通的read和write函数进行通信了

  • 总之,UICI服务器按如下步骤工作:

    • 打开一个知名的监听端口(u_open)。u_open函数返回一个监听文件描述符(listening file descriptor)
    • 在监听文件描述符上等待连接请求(u_accept)。u_accept函数一直阻塞,直到有客户机请求连接为止,然后它返回一个通信文件描述符(communication file descriptor),并将这个文件描述符用作私有双工客户机-服务器通信的句柄
    • 通过通信文件描述符(read和write)与客户机进行通信
    • 关闭通信文件描述符(close)
  • UICI客户机按如下步骤工作:

    • 连接到一个指定的主机和端口(u_connect)。连接请求返回与服务器进行双工通信时使用的通信文件描述符
    • 通过通信文件描述符(read和write)与服务器通信
    • 关闭通信文件描述符(close)

UICI的套接字实现

  • 通过使用带有TCP的套接字实现UICI API的概况

    • socket – 创建通信端点
    • bind – 将端点与指定的端口相关联
    • listen – 将端点设置为被动的监听者
    • accept – 接收来自客户机的连接请求
    • socket – 创建通信端点
    • connect – 请求向服务器建立连接
  • 服务器创建一个句柄(socket),将它与网络上的一个物理位置相关联(bind),然后设置挂起请求的队列长度(listen)

  • UICI的u_open函数中封装了这三个函数,它返回一个对应于被动或监听套接字的文件描述符,然后,服务器监听客户机的请求(accept)

  • 客户机也创建一个句柄(socket),并将这个句柄与服务器的网络位置相关联(connect)

  • UICI的u_connect函数封装了这两个函数。

  • 服务器和客户机句柄是文件描述符,有时也将它们称作通信端点(communication endpoint)或传输端点(transmission endpoint)

  • 一旦客户机和服务器建立了连接,它们就可以通过普通的read和write调用进行通信了

  • socket函数,创建了一个通信端点并返回一个文件描述符

    • #include <sys/socket.h>
    • int socket(int domain, int type, int protocol);
  • 参数

    • domain – 选择所用的协议族,AF_INET,代表IPv4
    • type
      • SOCK_STREAM, 表示有序,可靠,双工,面向连接的字节流,通常由TCP实现
      • SOCK_DGRAM, 通过定长的,不可靠消息提供无连接通信,通常由UDP实现
    • protocol– 指定特定的通信type使用的协议。在大多数实现中,每个type参数只能使用一种协议。例如,SOCK_STREAM使用TCP, SOCK_DGRAM使用UDP
  • 返回值:

    • 成功 – 返回一个对应于套接字文件描述符的非负整数
    • 失败 – 返回-1,并设置errno
  • 使用面向连接的协议为因特网通信创建一个套接字通信端点

    • int sock;
    • if ((sock = socket(AF_INET, SOCK_STREAM, 0) == -1))
      • perror("Failed to create socket!\n);
  • bind函数,将套接字通信端点的句柄与一个特定的逻辑网络连接关联起来。因特网域协议用端口号来指定逻辑连接

    • #include <sys/socket.h>
    • int bind(int socket, const struct sockaddr *address, socklen_t address_len);
  • 参数:

    • socket – 前一个socket函数调用返回的文件描述符
    • *address – 该结构中包含一个协议族名和与协议相关的信息
    • address_len – 是*address结构中的字节数
  • 返回值:

    • 成功 – 返回0
    • 失败 – 返回-1,并设置errno
  • 因特网域用struct sockaddr_in代替struct sockaddr

  • POSIX规定应用程序在和套接字函数一同使用时,要将struct sockaddr_in强制转换成struct sockaddr

  • netinet/in.h中定义的struct sockaddr_in结构至少包含下列成员,这些成员都是用网络字节顺序来表示的

    • sa_family_t sin_family; /* AF_NET */
    • in_port_t sin_port; /* port number */
    • struct in_addr sin_addr; /* IP address */
  • 对因特网通信来说,sin_family的值为AF_INET, sin_port指的是端口号

  • struct in_addr结构有一个被称为s_addr的成员,s_addr成员是in_addr_t类型,装载了因特网地址的数字值

  • 服务器可以将sin_addr.s_addr字段设置为INADDR_ANY,表示套接字应该接收任何一个主机网络接口上的连接请求

  • 客户机将sin_addr.s_addr字段设置为服务器主机的IP地址

  • 将端口8652与一个对应于打开的文件描述符sock的套接字相关联

    sockaddr_in server;
    1
    2
    3
    4
    5
    6
    int sock;
    server.sin_family = AF_INET;
    server.sin_addr.s_addr = htonl(INADDR_ANY);
    server.sin_port = htons((short)8652);
    if (bind(sock, (struct sockaddr *)&server, sizeof(server)) == -1)
    perror("Failed to bind the socket to port !\n);`
  • htonlhtons将INADDR_ANY和8652的字节按照网络字节顺序重新排序

    • htonl函数对long(长整数)重新排序,将其从主机字节顺序转换为网络字节顺序
    • htons函数将short(短整数)重新排序为网络字节顺序
  • 它们的镜像函数ntohlntohs对整数进行重新排序,将整数从网络字节顺序转到主机字节顺序

  • 大尾数计算机先存储最高有效字节(most significant byte), 小尾数计算机先存储最低有效字节(least significant type) – 大端,小端

  • 当使用不同字节存放次序的计算机进行通信时,整数的字节顺序会带来一个问题, 因为不同的计算机会对端口号这样的协议信息产生错误的理解

  • 不幸的是,这两种字节存放次序都很常见

    • SPARC结构(由Sun Microsystems公司开发)采用大数在先结构
    • 而Intel结构采用小数在先结构
  • 因特网协议规定网络字节顺序(network byte order),采用大数在先结构,POSIX要求某些套接字地址字段按网络字节顺序给出

  • socket函数创建了一个通信端点,而bind函数将这个通信端点与一个特定的网络地址相关联。

  • 此时,客户机可以用套接字与服务器进行连接。要用套接字来接收连接请求,应用程序必须通过调用listen函数将套接字设置成被动状态

  • listen函数使底层的系统网络基础结构分配队列以承载那些待处理的连接请求

    • #include <sys/socket.h>
    • int listen(int socket, int backlog);
  • 当客户机发出连接请求时,客户机和服务器网络子系统交换信息(TCP的三次握手)以建立连接

  • 因为服务器可能正忙,所以主机的网络子系统会将客户机的连接请求排队,直到服务器准备好接收这些请求为止

  • 如果服务器主机拒绝了客户机的连接请求,客户机会收到一个ECONNREFUSED错误。

  • 参数:

    • socket值,就是上一次socket调用返回的描述符,
    • backlog,给出了允许排队等待的客户机请求数目的最大值
  • 返回值:

    • 成功 – 返回0
    • 失败 – 返回-1,并设置errno
  • 建立了一个被动的监听套接字(socket, bind, listen)之后,服务器通过调用accept函数来处理到来的客户机连接

    • #include <sys/socket.h>
    • int accept(int socket, struct sockaddr *restrict address, socklen_t *restrict address_len);
  • accept的参数与bind的参数类似,

    • 但是,bind函数要求在调用之前将*address字段填好,这样它才能知道服务器会在哪个端口和接口上接收连接请求
    • 与之相反,accept函数用*address字段来返回与建立连接的客户机有关的信息。尤其要支出的是,struct sockaddr_in结构的sin_addr成员中包含一个s_addr成员,这个成员中装载了客户机的因特网地址
    • accept函数的*address_len参数的值指定了address指向的缓冲区的长度。在调用之前,要在这个参数中填上*address结构的长度,调用之后,*address_len中函数的是由accept调用实际填写的缓冲区字节数
  • 返回值:

    • 成功 – 返回对应于已接收套接字的非负文件描述符
    • 失败 – 返回-1,并设置errno
  • 客户机调用socket来建立一个传输端点,然后用connect来建立远程服务器知名端口的连接

    • #include <sys/socket.h>
    • int connect(int socket, const struct sockaddr *address, socklen_t address_len);
  • connect像bind一样填写struct sockaddr结构

  • 返回值:

    • 成功 – 返回0
    • 失败 – 返回-1,并设置errno

主机名和IP地址

  • 对大多数网络库调用来说,主机名都必须映射成数字网络地址

  • 作为系统配置的一部分,系统管理员要定义将名字翻译成网络地址的机制。这个机制可能包括本地表查询,如果必要的话,还可以对域名服务器进行查询。

  • 域名服务(Domain Name Service, DNS),是整合因特网命名的粘合剂

  • 一般来说,主机可以由它的名字或者地址来指定。程序中的主机名通常用ASCII字符串来标识

  • IPv4地址可以用二进制格式(采用与struct in_addr的s_addr字段一样的网络字节顺序)或人类易读的格式表示,这种易读的格式被称作点分十进制表示法(dotted-decimal notation)或因特网地址点分表示法(Internet address dot notation)

  • 地址的点分形式是一个字符串,这个字符串的值是以小数点分隔,用十进制表示的四个字节

  • IPv4地址的二进制表示有4字节长。因此4字节地址没有为未来的因特网扩展提供足够的空间,所以这个协议的新版本IPv6,采用了16字节的地址结构

  • inet_addr和inet_ntoa函数在点分十进制表示法和struct sockaddr_in的struct in_addr字段中使用二进制网络字节顺序格式之间进行转换

  • inet_addr函数将采用点分十进制表示法的地址转换成采用网络字节顺序的二进制地址。得到的值可以直接存储在struct sockaddr_in的sin_addr.s_addr字段中

    • #include <arpa/inet.h>
    • in_addr_t inet_addr(const char *cp);
  • 返回值:

    • 成功 – 返回因特网地址
    • 失败 – 返回-1
  • inet_ntoa函数,接收一个struct in_addr结构,这个结构中包含一个采用网络字节顺序的二进制地址,并返回相应的用点分十进制表示法表示的字符串

    • #include <arpa/inet.h>
    • char *inet_ntoa(const struct in_addr in);
  • 二进制地址可以从struct sockaddr_in结构的sin_addr字段中得到

  • 返回的字符串是静态分配的,因此在线程化应用程序中使用inet_ntoa可能不安全

  • 返回值:

    • 返回一个指向网络地址的指针,这个网络地址是用因特网标准的点分表示法表示的
  • 将主机名转换成二进制地址的传统方法是调用gethostbyname函数

    • #include <netdb.h>
    • struct hostent *gethostbyname(const char *name);
  • 函数将主机名字符串作为参数,并返回一个指向struct hostent结构的指针,该结构中包含相应主机的名字和地址信息

  • 返回值:

    • 成功 – 返回一个指向struct hostent指针
    • 失败 – 返回一个NULL指针,并设置errno
  • 从地址到名字的转换可以用gethostbyaddr实现,

    • #include <netdb.h>
    • struct hostent *gethostbyaddr(const void *addr, socklen_t len, int type);
  • 对IPv4来说,type应该是AF_INET,len的值应该是4字节,参数addr应该指向一个struct in_addr结构

  • 返回值:

    • 成功 – 返回一个指向struct hostent结构的指针
    • 失败 – 返回一个NULL指针,并设置errno
  • 在主机名和地址之间进行转换的第二种方法是使用getnameinfogetaddrinfo,它们在2001年首次称为被认可的POSIX标准

    • #include <sys/socket.h>
    • #include <netdb.h>
    • void freeaddrinfo(struct addrinfo *ai);
    • int getaddrinfo(const char *restrict nodename, const char *restrict servname, const struct addrinfo *restrict hints, struct addrinfo **restrict res);
    • int getnameinfo(const struct sockaddr *restrict sa, socklen_t salen, char *restrict node, socklen_t nodelen, char *restrict service, socklen_t servicelen, unsigned flags);
  • 返回值:

    • 成功 – 返回0
    • 失败 – 返回一个错误码
  • 使用uname来获取主机名

    • #include <sys/utsname.h>
    • int uname(struct utsname *name);
  • 返回值:

    • 成功 – 返回一个非负值
    • 失败 – 返回-1,并设置errno
  • sys/utsname.h中定义的struct ustname结构至少包含下列成员

    • char sysname[]; /* 本OS实现的名字 */
    • char nodenamep[]; /* 在通信网络中本节点的名字 */
    • char release[]; /* 本实现当前发布的级别 */
    • char version[]; /* 本次发布的当前版本的级别 */
    • char machine[]; /* 系统正在运行的硬件类型名 */

WWW重定向 (497)

  • 万维网采用客户机-服务器体系结构,这种结构基于一种资源表示方案(URI),一种通信协议(HTTP)和一种文档格式(HTML),三者共同作用使用户可以很方便地进行信息的访问和交互

万维网

  • 万维网,是客户机和服务器的集合,这些客户机和服务器商定好以特定的格式来进行交互和信息交换

统一资源定位符

  • 统一资源定位符(Universal Resource Locator, URL)的格式为 – 模式:位置(scheme : location)

    • 模式(scheme),指的是访问资源的方法,例如HTTP
    • 位置(location),则说明了资源放在哪里
  • 当用户通过浏览器打开一个URL时,浏览器解析服务器的主机名并建立一个到那个服务器主机上指定端口的TCP连接

  • 然后,浏览器通过下一节中描述的HTTP协议向服务器发送一个资源请求,请求URL的绝对路径指定的资源

HTTP入门

  • 在客户端和Web服务器上都有一个被称为超文本传输协议(HyperText Transfer Protocol, HTTP)的特定的规则集合。
  • 这个规则的集合也可以称为协议(protocol),客户机和服务器通过这个协议来交换信息

Web通信模式

  • 根据HTTP的术语,客户(client),是建立连接的应用程序,服务器(server),是接受连接并做出相应的应用程序。

  • 用户代理(user agent),是一个发起服务请求的客户。

  • 根据这些术语,浏览器就既是客户又是用户代理

  • 起源服务器(origin server),是一个拥有资源的服务器

  • 隧道(tunnel),是一个充当盲中继(blind relay)的中间体,隧道不解析HTTP,而是将它传送给服务器。

  • 隧道从客户端接受一个HTTP连接并建立一个到服务器的连接。在这种情况下,尽管它既不是用户代理也不是起源服务器,但根据HTTP的定义,隧道即充当客户端,又充当服务器

  • 隧道将信息从客户端传递到服务器。当服务器响应时,隧道就将响应传送到客户端。

  • 代理(proxy),是一种中间体,它在客户端和服务器之间,代表它的客户发起请求

  • 客户通过一种特殊形式的GET向代理发出请求,而且代理必须解析HTTP。

  • 与隧道一样,代理也是即充当客户端又充当服务器。但是,代理存在的时间通常很长,而且通常会充当多个客户端的中间体

  • 透明代理(transparent proxy),除了在代理的标识和鉴权方面所需的修改之外,不对请求或应答进行修改

  • 非透明代理(nontransparent proxy),可能会代表它们的客户端执行很多其他类型的服务,例如注释,匿名过滤,内容过滤,审查,媒体转换等

  • 代理可以保存与它们的客户有关的统计信息和其他信息

  • Google这样的搜索引擎是另外一种类型的代理,它缓存了与页面内容和指向页面的URL有关的信息。用户可以通过关键词或短语来访问缓存的信息

  • 代理代表客户执行的最重要的服务就是高速缓存。

  • 高速缓存(cache),是响应信息在本地的存储。浏览器通常会将近期的响应消息缓存在磁盘上。当用户打开一个URL时,浏览器首先查看在磁盘上能够找到资源,只有当它在本地找不到对象时,才会启动一个网络请求

  • 代理高速缓存(proxy cache),将它读取的资源存储起来,以便更有效地为将来申请这些资源的请求服务

  • 通常,代理高速缓存都安装在局域网的网管上。本地网络中的客户通过这个代理来转发它所有的请求。

  • 可以用代理高速缓存的本地存储中的对象来响应来自不同用户的请求。如果已经有人请求过这个对象,而且代理缓存了这个对象,那么对当前请求的响应就要快得多了

  • 可以将代理看成客户端的中间体,那么,网关(gateway),则是服务端的机制。

  • 网关可以接收请求,就像它是起源服务器一样。网关可以位于局域网的边界路由器上,也可以位于保护内部网的防火墙之外。

  • 网关可以提供很多服务,例如安全,翻译和负载平衡。网关可以作为某个组织的一群Web服务器的公共接口,也可以作为位于防火墙之内的Web服务器的前端门户使用

  • 网关和隧道有什么不同?

    • 隧道,是一种管道,它将信息从一个点传到另一个点,不对信息进行修改。
    • 网关,则充当资源的前端,可能还会充当一群服务器的前端

各种服务器的常见的缺陷和错误

  • 线程和时序错误

  • 这类程序的大部分时序错误都是由于对TCP的不正确理解造成的。

  • 就算提供了一个足够大的缓冲区,也不要假定在单个读操作中就能读入整个请求。TCP提供了对一个没有分组和消息便捷的字节流的抽象。

  • 未捕捉到的错误和错误的退出

  • 你的服务器对错误会有什么样的响应,服务器在什么时候应该退出,如果没有认真或正确解决这些问题,运行的程序对程序可能就是一个很大的威胁,尤其是当以很高的特权级别运行时

  • 服务器通常应该一直运行下去,直到系统重启为止,因此要考虑退出的策略。不要从除了main函数之外任何其他的函数中退出。

  • 总的来说,其他的函数或者应该对错误进行处理,或者应该向调用程序返回一个错误码。客户不应当会造成服务器的退出。只有由于资源(内存,描述符等)缺乏出现了无法恢复的错误而危及到未来的正确执行时,服务器才能退出

  • 即使库函数返回了一个错误,C程序仍然不加理会地继续执行,就有可能在后继的执行中造成一个致命的,实际上无法定位的错误。

  • 要避免这类问题,就要对每一个能够返回错误的库函数的返回值进行检查

  • 释放资源总是很重要的。在服务器中,这是非常关键的。当客户端的通信结束时,要关闭所有相应的文件描述符。如果函数分配了缓冲区,就一定要在某个地方将其释放掉。查看那些资源是否在函数执行的每条路径上都被释放了,要特别注意出现错误时会发生什么情况

  • 确定函数什么时候应该输出一条错误消息,什么时候应该返回一个错误码。用条件编译将通知性的消息放在源代码中,但不要让它们出现在发布的应用程序中

  • 记住:

    • 在现实世界里,这些消息都应该有地方可去,可能是到一些不走运的控制台日志里去
    • 将消息写到标准错误而不是标准输出中去
    • 通常,标准错误会被重定向到一个控制台日志中去。同样,系统不对标准错误进行缓冲,因此出现错误时,消息就会显示出来
  • 编程错误和不好的风格

  • 要避免大的或不相容的缩进。也要避免大的循环,可以使用函数来降低复杂性

  • 不要做重复的工作。如果可能,就使用库函数,此外,还要合并通用的代码

  • 随时都要释放分配了的资源,例如缓冲区,但是不要多次释放它们,因为这样会造成随后的资源分配失败

第22章 服务器性能(572)

  • 三种客户机服务器通信模型

    • 串行服务器
    • 父-服务器
    • 线程化服务器
  • 因为父-服务器策略对每个客户端请求都创建一个新的子进程,有时也被称为每个请求一个进程策略(process-per-request)

  • 类似的,线程化服务器策略对每个请求创建一个单独的线程,所以也经常被称为每个请求一个线程策略(thread-per-request)

  • 一种变通的策略是在接收请求之前,先创建一些进程或线程,构建一个工作者池(worker pool)

  • 工作者们都在同步点阻塞,等待请求到达。每个到达的请求激活一个线程或进程,其余的则继续阻塞。

  • 工作者池消除了创建线程或进程的开销,但是带来了额外的同步开销。同时,性能和池的大小密切相关。

  • 灵活的实现可能会动态调整池中的线程或进程数量来维持系统的平衡

  • 缓冲区池的方法能用一个子进程的池来实现吗?

    • 通信文件描述符是小的整型数值,用来表示文件描述符表中的一个位置。
    • 这个数值只在同一个进程内的上下文之间有意义,所以用子进程来实现缓冲区池是不可能的
  • 在每个请求一个线程(thread-per-request)的体系结构中,主线程阻塞在accept调用上,并为每个请求创建一个线程。

  • 而在工作者池的方法中,池的大小限制了竞争资源的并发线程数。每个请求一个线程的设计,如果没有仔细的监视,就容易发生资源过度分配

  • 何为每个请求一个进程(process-per-request)策略?如何实现它?

    • 每个请求一个进程的策略类似每个请求一个线程的策略。
    • 服务器接收请求,创建子进程(而不是创建新线程)来处理它。
    • 因此主进程在得到了通信文件描述符之后才去创建子进程,子进程继承了其文件描述符表,所以该通信文件描述符对于子进程也是有效的

简介

  • 算法导论 阅读笔记

第一章 算法在计算中的作用

算法

  • 非形式地说,算法(algorithm)就是任何良定义的计算过程,该过程取某个值或值的集合作为输入并产生某个值或值的集合作为输出。这样算法就是把输入转换成输出的计算步骤的一个序列。

  • 一般来说,问题实例由计算该问题解所必须的(满足问题陈述中强加的各种约束的)输入组成。

  • 因为许多程序使用排序作为一个中间步,所以排序是计算机科学中的一个基本操作。

第二章 算法基础

插入排序

  • 插入排序,对于少量元素的排序,它是一个有效的算法

分治法

  • 许多有用的算法在结构上是递归的:

    • 为了解决一个给定的问题,算法一次或多次递归地调用其自身以解决紧密相关的若干子问题。
  • 这些算法典型的遵循分治法的思想:

    • 将原问题分解为几个规模较小但类似于原问题的子问题,递归地求解这些子问题,然后再合并这些子问题的解来建立原问题的解。
  • 分治模式在每层递归时都有三个步骤:

    • 分解原问题为若干子问题,这些子问题是原问题的规模较小的实例。
    • 解决这些子问题,递归地求解各子问题。然而,若子问题的规模足够小,则直接求解。
    • 合并这些子问题的解成原问题的解
  • 归并排序算法完全遵循分治模式。直观上其操作如下:

    • 分解:分解待排序的N个元素的序列成各觉有N/2个元素的两个子序列。
    • 解决:使用归并排序递归地排序两个子序列
    • 合并:合并两个已经排序的子序列以产生已排序的答案。

分析分治算法

  • 当一个算法包含对其自身的递归调用时,我们往往可以用递归方程或递归式来描述其运行时间,该方程根据在较小输入上的运行时间来描述在规模为n的问题上的总运行时间。然后,我们可以使用数学工具来求解该递归式并给出算法性能的界

第三章 函数的增长

渐近记号

  • 我们将主要使用渐近记号来描述算法的运行时间

第四章 分治策略

  • 归并排序利用了分支策略。在分支策略中,我们递归地求解一个问题,在每层递归中应用如下三个步骤:

    • 分解(Divide)步骤将问题划分为一些子问题,子问题的形式与原问题一样,只是规模更小。
    • 解决(Conquer)步骤递归地求解出子问题。如果子问题的规模足够小,则停止递归,直接求解。
    • 合并(Combine)步骤将子问题的解组合成原问题的解
  • 当子问题足够大,需要递归求解时,我们称之为递归情况(recursive case).

  • 当子问题变得足够小,不再需要递归时,我们说递归已经“触底”,进入了基本情况(base case)。有时,除了与原问题形式完全一样的规模更小的子问题外,还需要求解与原问题不完全一样的子问题。我们将这些子问题的求解看做合并步骤的一部分。

  • 递归式

    • 递归式与分支方法是紧密相关的。因为使用递归式可以很自然地刻画分支算法的运行时间。
    • 一个递归式(recurrence)就是一个等式或不等式,他通过更小的输入上的函数值来描述一个函数。

第五章 概率分析和随机算法

概率分析

  • 概率分析是在问题分析中应用概率的理念。

  • 更一般的,如果一个算法的行为不仅由输入决定,而且也由随机数生成器(random-number generator)产生的数值决定,则称这个算法是随机的(randomized)

简介

  • 计算机科学的各个方面都离不开程序设计语言。计算机工作者一生中必然会接触好几种语言:当一种新的语言问世并被广泛接受时,你需要学习这种语言以更新技能;当接受一个新项目时,你必须为这个项目选择一种最合适的实现语言;甚至你可能会为它专门设计并实现一种新的语言。
  • 本书并不教授如何使用一种语言,而是讨论程序设计语言的结构与特性,这些结构与特性在不同语言中的设计与实现以及这些结构与特性给语言的优点与缺点。

简介

设计模式是什么?

  • 设计模式,是软件设计中常见问题的典型解决方案。它们就像能根据需求进行调整的预制蓝图,可用于解决代码中反复出现的设计问题。

  • 设计模式与方法或库的使用方式不同,你很难直接在自己的程序中套用某个设计模式。

  • 模式并不是一段特定的代码,而是解决特定问题的一般性概念。你可以根据模式来实现符合自己程序实际所需的解决方案

    • 类库,是由程序组合而成的组件,
    • 而设计模式则用来表现内部组件是符合被组装的,以及每一个组件是如何通过相互关联来构成一个庞大系统的
  • 人们常常会混淆模式和算法,因为两者在概念上都是已知特定问题的典型解决方案。

  • 但算法总是明确定义达成特定目标所需的一系列步骤,而模式则是对解决方案的更高层次描述。同一模式在两个不同程序中的实现代码可能会不一样

  • 设计模式的目标之一,就是提高程序的可复用性

  • 算法更像是菜谱:提供达成目标的明确步骤。

  • 而模式更像是蓝图:你可以看到最终的结果和模式的功能,但需要自己确定实现步骤

  • 模式包含哪些内容?

    • 意图部分,简单描述问题和解决方案
    • 动机部分,将进一步解释问题并说明模式会如何提供解决方案
    • 结构部分,展示模式的每个部分和它们之间的关系
    • 在不同语言中的实现提供流行编程语言的代码, 让读者更好地理解模式背后的思想

模式的历史

  • 谁发明了设计模式? 这是一个很好的问题, 但也有点不太准确。

  • 设计模式并不是晦涩的、复杂的概念——事实恰恰相反。模式是面向对象设计中常见问题的典型解决方案。同样的解决方案在各种项目中得到了反复使用,所以最终有人给它们起了名字,并对其进行了详细描述。这基本上就是模式被发现的历程了

  • 模式的概念是由克里斯托佛·亚历山大在其著作《建筑模式语言》中首次提出的。本书介绍了城市设计的 “语言”, 而此类 “语言” 的基本单元就是模式。模式中可能会包含对窗户应该在多高、一座建筑应该有多少层以及一片街区应该有多大面积的植被等信息的描述

  • 埃里希·伽玛、约翰·弗利赛德斯、拉尔夫·约翰逊和理查德·赫尔姆这四位作者接受了模式的概念。 1994 年,他们出版了《设计模式: 可复用面向对象软件的基础》一书,将设计模式的概念应用到程序开发领域中。

  • 该书提供了 23 个模式来解决面向对象程序设计中的各种问题,很快便成为了畅销书。由于书名太长,人们将其简称为 “四人组 (Gang of Four, GoF) 的书”, 并且很快进一步简化为 “GoF 的书

  • 此后,人们又发现了几十种面向对象的模式。“模式方法” 开始在其他程序开发领域中流行起来。如今,在面向对象设计领域之外,人们也提出了许多其他的模式

为什么以及如何学习设计模式?

  • 设计模式是针对软件设计中常见问题的工具箱,其中的工具就是各种经过实践验证的解决方案。 即使你从未遇到过这些问题,了解模式仍然非常有用,因为它能指导你如何使用面向对象的设计原则来解决各种问题

  • 设计模式定义了一种让你和团队成员能够更高效沟通的通用语言。 你只需说 “哦, 这里用单例就可以了”, 所有人都会理解这条建议背后的想法。 只要知晓模式及其名称, 你就无需解释什么是单例

关于模式的争议

  • 一种针对不完善编程语言的蹩脚解决方案

    • 通常当所选编程语言或技术缺少必要的抽象功能时, 人们才需要设计模式。 在这种情况下, 模式是一种可为语言提供更优功能的蹩脚解决方案
    • 例如, 策略模式在绝大部分现代编程语言中可以简单地使用匿名 (lamb­da) 函数来实现。
  • 低效的解决方案

    • 模式试图将已经广泛使用的方式系统化。 许多人会将这样的统一化认为是某种教条, 他们会 “全心全意” 地实施这样的模式, 而不会根据项目的实际情况对其进行调整
  • 不当使用

    • 如果你只有一把铁锤, 那么任何东西看上去都像是钉子。
    • 这个问题常常会给初学模式的人们带来困扰: 在学习了某个模式后, 他们会在所有地方使用该模式, 即便是在较为简单的代码也能胜任的地方也是如此

设计模式分类

  • 不同设计模式的复杂程度、 细节层次以及在整个系统中的应用范围等方面各不相同

  • 最基础的、 底层的模式通常被称为惯用技巧。 这类模式一般只能在一种编程语言中使用

  • 最通用的、 高层的模式是构架模式。 开发者可以在任何编程语言中使用这类模式。 与其他模式不同, 它们可用于整个应用程序的架构设计。

  • 此外, 所有模式可以根据其意图或目的来分类。 本书覆盖了三种主要的模式类别:

    • 创建型模式提供创建对象的机制, 增加已有代码的灵活性和可复用性。
    • 结构型模式介绍如何将对象和类组装成较大的结构, 并同时保持结构的灵活和高效。
    • 行为模式负责对象间的高效沟通和职责委派。

Iterator模式

  • for循环语句遍历数组,for语句中的i++的作用是让i的值在每次循环后自增1,这样就可以访问数组的下一个元素,下下一个元素,再下下一个元素,也就实现了从头至尾逐一遍历的功能

  • 将这里的循环变量i的作用抽象化,通用化后形成的模式,在设计模式中成为Iterator模式

  • Iterator模式,用于在数据集合中按照循序遍历集合。英文单词Iterate有反复做某件事情的意思,汉语称为“迭代器”

  • 为什么一定要考虑引入Iterator这种复杂的设计模式?如果是数组,直接使用for循环语句进行遍历处理不就可以了?

    • 一个重要的理由是,引入Iterator后可以将遍历与实现分离开来
  • 设计模式的作用就是帮助编写可复用的类,

  • 所谓“可复用”,就是指将类实现为“组件”,当一个组件发生改变时,不需要对其他的组件进行修改或者只是需要很小的修改即可应对。

  • 抽象类和接口

    • 人们总想用具体的类来解决所有的问题
    • 但是如果只使用具体的类来解决问题,很容易导致类之间的强耦合,这些类也难以作为组件被再次利用。
    • 为了弱化类之间的耦合,进而使得类更加容易作为组件被再次利用,需要引入抽象类接口
  • 不要只使用具体类来变成,要优先使用抽象类和接口来编程

Adapter模式

  • 在程序世界中,经常会存在现有的程序无法直接使用,需要做适当的变换之后才能使用的情况。

  • 这种用于填补“现有的程序”和“所需的程序”之间差异的设计模式就是Adapter模式

  • Adapter模式,也被称为Wrapper模式。Wrapper有“包装器”的意思,就像精美的包装纸将普通商品包装成礼物那样,替我们把某样东西包起来,使其能够用于其他用途的东西就被称为“包装器”或者是“适配器”

  • Adapter模式有两种

    • 类适配器模式(使用继承的适配器)
    • 对象适配器模式(使用委托的适配器)
      • 委托,通俗来讲,就是交给其他人,在Java语言中,委托就是将某个方法中的实际处理交给其他实例的方法
  • 什么时候使用Adapter模式?

    • 有人认为:如果某个方法就是我们所需要的方法,那么直接在程序中使用不就可以了?为什么还要考虑使用Adapter模式呢?
    • 很多时候,我们并非从零开始编程,经常会用到现有的类。
    • 特别是当现有的类已经被充分测试过了,Bug很少,而且已经被用于其他软件时,我们更愿意将这些类作为组件重复利用
    • Adapter模式会对现有的类进行适配,生成新的类。通过该模式可以很方便地创建我们需要的方法群
  • 版本升级和兼容性

    • 版本的生命周期总是伴随版本的升级,而在版本升级的时候经常会出现“与旧版本的兼容性”的问题。
    • 如果能够完全抛弃旧版本,那么软件的维护工作将会轻松很多,但是现实中往往无法这样做。
    • 这时,可以使用Adapter模式使新旧版本兼容,帮助我们轻松的同时维护新版本和旧版本
  • 功能完全不同的类,Adapter模式是无法使用的

Template Method模式

  • 什么是模板?

    • 模板的愿意是指带有镂空文字的薄薄的塑料板
  • 什么是Template Method模式?

    • Template Method模式是带有模板功能的模式,组成模板的方法被定义在父类中。
    • 由于这些方法是抽象方法,所以只查看父类的代码是无法知道这些方法最终会进行何种具体处理的,唯一能够知道的就是父类是如何调用这些方法的。
  • 实现上述这些抽象方法的是子类。在子类中实现了抽象方法也就决定了具体的处理。也就是说,只要在不同的子类中实现不同的具体处理,当父类的模板方法被调用时程序行为也会不同。

  • 但是,不论子类中的具体实现如何,处理的流程都会按照父类中所定义的那样进行。

  • 像这样在父类中定义处理流程的框架,在子类中实现具体处理的模式就称为Template Method模式

延伸阅读:类的层次与抽象类

  • 我们在理解类的层次时,通常是站在子类的角度进行思考的。也就是说,很容易着眼于以下几点

    • 在子类中可以使用父类中定义的方法
    • 可以通过在子类中增加方法以实现新的功能
    • 在子类中重写父类的方法可以改变程序的行为
  • 改变一下立场,站在父类的角度进行思考。在父类中,我们声明了抽象方法,而将该方法的实现交给了子类。换言之,就程序而言,声明抽象方法是希望达到以下目的

    • 期待子类去实现抽象方法
    • 要求子类去实现抽象方法
  • 也就是说,子类具有实现父类中所声明的抽象方法的责任。因此,这种责任被称为“子类责任(subclass responsibility)”

  • 抽象类的意义

    • 对于抽象类,我们是无法生成其实例的。
    • 由于在抽象方法中并没有编写具体的实现,所以我们无法知道在抽象方法中到底进行了什么样的处理。
    • 但是我们可以决定抽象方法的名字,然后通过调用使用了抽象方法的模板方法去编写处理。虽然具体的处理内容是由子类决定的,不过在抽象类阶段确定处理的流程非常重要。

父类与子类之间的写作

  • 父类与子类的相互协作支撑起了整个程序。
  • 虽然将更多方法的实现放在父类中会让子类变得更轻松,但是同时也降低了子类的灵活性
  • 如果父类中实现的方法过少,子类就会变得臃肿不堪,而且还会导致各子类间的代码出现重复

Factory Method模式

  • Factory有“工厂”的意思。用Template Method模式来构建生成实例的工厂,这就是Factory Method模式

  • 在Factory Method模式中,父类决定实例的生成方式,但是并不决定所要生成的具体的类,具体的处理全部交给子类负责。这样就可以将生成实例的框架(framework)和实际负责生成实例的类解耦

使用模式与开发人员之间的沟通

  • 无论是Template Method模式还是Factory Method模式,在实际工作中使用时,都会让我们感到比较困难。
  • 这是因为,如果仅阅读一个类的代码,是很难理解这个类的行为的。必须要理解父类中所定义的处理的框架和它里面所使用的的抽象方法,然后阅读代码,了解这些抽象方法在子类中的实现才行。

Singleton模式

  • 想确保任何情况下都绝对只有1个实例,在程序上表现出“只存在一个实例”。想这样的确保只生成一个实例的模式被称为Singleton模式。

  • Singleton是指只包含有一个元素的集合。

  • 为什么必须设置限制?

    • 设置限制其实就是为程序增加一项前提条件
    • 当存在多个实例时,实例之间相互影响,可能会产生意想不到的Bug
    • 但是,如果我们可以确保只有一个实例,就可以在这个前提条件下放心地编程

Builder模式

  • Builder模式:用于组装具有复杂结构的实例

谁知道什么

  • 在面向对象编程中,“谁知道什么”是非常重要的。也就是说,我们需要在编程时注意哪个类可以使用哪个方法以及使用这个方法到底好不好

设计时能够决定的事情和不能决定的事情

  • 虽然类的设计者并不是神仙,无法准确地预测到将来可能发生的变化。但是,还是有必要让设计出的类能够尽可能灵活地应对近期可能发生的变化

代码的阅读方法和修改方法

  • 在编程时,虽然有时需要从零开始编写代码,但更多时候我们都是在现有代码的基础上进行增加和修改

  • 这时,我们需要先阅读现有代码。不过,只是阅读抽象类的代码是无法获取很多信息的(虽然可以从方法名中获得线索)

  • 如果没有理解各个类的角色就动手增加和修改代码,在判断到底应该修改哪个类时,就会很容易出错

Abstract Factory模式

  • Abstract的意思是“抽象的”,Factory的意思是“工厂”,将他们组合起来就可以知道Abstract Factory表示“抽象工厂”的意思。

  • 抽象工厂的工作是将“抽象零件”组装为“抽象产品”

  • 面向对象编程中的“抽象”

    • 它指的是“不考虑具体怎样实现,而是仅关注接口(API)”的状态
    • 例如,抽象方法(Abstract Method)并不定义方法的具体实现,而是仅仅只确定了方法的名字和签名(参数的类型和个数)

Bridge模式

  • Bridge的意思是“桥梁”。就像在现实世界中,桥梁的功能是将河流的两侧连接起来一样,Bridge模式的作用也是将两样东西连接起来,他们分别是类的功能层次结构和类的实现层次结构

  • Bridge模式的作用是在“类的功能层次结构”和“类的实现层次结构”之间搭建桥梁,换句话说,将类的功能层次结构与实现层次结构分离

  • 类的功能层次结构

    • 假设现在有一个类Something.当我们想在Something中增加新功能时(想增加一个具体方法时),会编写一个Something类的子类(派生类),即SomethingGood类。这样就构成了一个小小的类层次结构
    • 这就是为了增加新功能而产生的层次结构:父类具有基本功能,在子类中增加新的功能
    • 以上这种层次结构被称为类的功能层次结构
    • 当要增加新的功能时,可以从各个层次的类中找出最符合自己需求的类,然后以它为父类编写子类,并在子类中增加新的功能。这就是类的功能层次结构
  • 类的实现层次结构

    • 抽象类声明了一些抽象方法,定义了接口(API),然后子类负责去实现这些抽象方法。父类的任务是通过声明抽象方法的方式定义接口(API),而子类的任务是实现抽象方法。正式由于父类和子类的这种任务分担,我们才可以编写出具有高可替换性的类
    • 这里其实也存在层次结构。例如,当子类ConcreteClass实现了父类AbstractClass类的抽象方法时,它们之间就构成了一个小小的层次结构。但是,这里的类的层次结构并非用于增加功能,也就是说,这种层次结构并非用于方便我们增加新的方法。它的真正作用是帮助我们实现两个任务分担
      • 父类通过声明抽象方法来定义接口(API)
      • 子类通过实现具体方法来实现接口(API)
    • 这种层次结构被称为类的实现层次结构
  • 类的层次结构的混杂与分离

    • 当我们想要编写子类时,就需要先确认自己的意图:是要增加功能呢?还是要增加实现呢?
    • 当类的层次结构只有一层时,功能层次结构与实现层次结构是混杂在一个层次结构中的。这样很容易使类的层次结构变得复杂,也难以透彻地理解类的层次结构。因为自己难以确认究竟应该在类的哪一个层次结构中去增加子类。
    • 因此,我们需要将“类的功能层次结构”与“类的实现层次结构”分离为两个独立的类层次结构。当然,如果只是简单地将他们分开,两者之间必然会缺少联系,所以我们还需要在他们之间搭建一座桥梁,而Bridge模式的作用就是搭建这座桥梁
  • Bridge模式的特征是将“类的功能层次结构”与“类的实现层次结构”分离开了。将类的这两个层次结构分离开有利于独立地对他们进行扩展

  • 当想要增加功能时,只需要在“类的功能层次结构”一侧增加类即可,不必对“类的实现层次结构”做任何修改。而且,增加后的功能可以被“所有的实现”使用

  • 继承是强关联关系,委托是弱关联关系。在设计类的时候,我们必须充分理解这一点

Strategy模式

  • Strategy的意思是“策略”,指的是与敌军对垒时行军作战的方法。**在编程中,我们可以将它理解为“算法”

  • 无论什么程序,其目的都是解决问题。而为了解决问题,我们又需要编写特定的算法。

  • 使用Strategy模式,可以整体地替换算法的实现部分。能够整体地替换换发,能让我们轻松地以不同的算法去解决同一个问题,这种模式就是Strategy模式

Composite模式

  • 在计算机的文件系统中,有文件夹的概念(在有些操作系统中,也称为目录)。文件夹里面既可以放入文件,也可以放入其他文件夹(子文件夹)。

  • 在子文件夹中,一样地既可以放入文件,也可以放入子文件夹。可以说,文件夹是形成了一种容器结构,递归结构

  • 文件夹和文件有时也被统称为“目录条目”(directory entry)。在目录条目中,文件夹和文件被当作是同一种对象看待(即一致性)

  • 有时,与将文件夹和文件都作为目录条目看待一样,将容器和内容作为同一种东西看待,可以帮助我们方便地处理问题。

  • 能够使容器与内容具有一致性,创造出递归结构的模式就是Composite模式。Composite在英文中是混合物,复合物的意思

Decorator模式

  • 不断地为对象添加装饰的设计模式,被称为Decorator模式。Decorator指的是“装饰物”

Visitor模式

  • 在Visitor模式中,数据结构与处理被分离开来。

  • 编写一个表示“访问者”的类来访问数据结构中的元素,并把对各元素的处理交给访问者类。这样,当需要增加新的处理时,我们只需要编写新的访问者,然后让数据结构可以接受访问者的访问即可

  • 双重分发

    • accept(接受)方法的调用方式:element.accept(visitor);
    • 而visit(访问)方法的调用方式:visitor.visit(element);
    • 对比两个方法会发现,他们是相反的关系。element接受visitor,而visitor又访问element
    • 在Visitor模式中,ConcreteElement和ConcreteVisitor这两个角色共同决定了实际进行的处理,这种消息分发的方式一般被称为双重分发(double dispatch)
  • Visitor模式的目的是将处理从数据结构中分离出来。数据结构很重要,它能将元素集合和管理在一起。

  • 但是,需要注意的是,保存数据结构与以数据结构为基础进行处理是两种不同的东西

Observer模式

  • Observer的意思就是“进行观察的人”,也就是“观察者”的意思

  • 在Observer模式中,当观察对象的状态发生变化时,会通知给观察者。Observer模式适用于根据对象状态进行相应处理的场景。

  • 利用抽象类和接口从具体类中抽出抽象方法

  • 在将实例作为参数传递至类中,或者在类的字段中保存实例时,不使用具体类型,而是使用抽象类型的接口

  • 这样的实现方式可以帮助我们轻松替换具体类

State模式

  • 在面向对象编程中,是用类表示对象的。

  • 也就是说,程序的设计者需要考虑用类来表示什么东西。类对应的东西可能存在于现实世界中,也可能不存在于现实世界中。对于后者,可能有人看到代码后会感到吃惊:这些东西居然也可以是类啊

  • 分而治之

    • 在编程时,会经常使用分而治之的方针。它非常适用于大规模的复杂处理。当遇到庞大且复杂的问题,不能用一般的方法解决时,会先将问题分解为多个小问题。如果还是不能解决这些小问题,会将它们继续划分为更小的问题,直至可以解决它们为止。
    • 分而治之,简单而言,就是将一个复杂的大问题分解为多个小问题然后逐个解决。
  • 在State模式中,用类来表示状态,并为每一种具体的状态都定义一个相应的类。这样问题就被分解了

  • 换言之,State模式用类表示系统的“状态”,并以此将复杂的程序分解开来

  • 在State模式中,我们应该如何编程,以实现“依赖于状态的处理”呢?总结起来有如下两点

    • 定义接口,声明抽象方法
    • 定义多个类,实现具体方法

Flyweight模式

  • Flyweight是“轻量级”的意思,指的是拳击比赛中选手体重最轻的登记。顾名思义,该设计模式的作用是为了让对象变“轻”

  • 对象在计算机中是虚拟存在的东西,它的“重”和“轻”并非指实际重量,而是它们“所使用的的内存大小”。使用内存多的对象就是“重”对象,使用内存小的对象就是“轻”对象

  • 为了能够在计算机中保存该对象,需要分配给其足够的内存空间。当程序中需要大量对象时,如果都是用new关键字来分配内存,将会消耗大量内存空间

  • 关于Flyweight模式,一言以蔽之就是 : 通过尽量共享示例来避免new出实例

  • Intrinsic与Extrinsic

    • 应当共享的信息被称作Intrinsic信息。Intrinsic的意思是“本质的,固有的”。
      • 换言之,它指的是不论实例在哪里,不论在什么情况下都不会改变的信息,或者是不依赖于实例的信息
    • 不应当共享的信息被称作Extrinsic信息。Extrinsic的意思是“外在的,非本质的”。
      • 也就是说,它是当实例的位置,状况发生改变时会变化的信息,或是依赖于实例状态的信息

Proxy模式

  • Proxy是“代理人”的意思,它指的是代替别人进行工作的人。
  • 在面向对象编程中,“本人”和“代理人”都是对象。如果“本人”对象太忙了,有些工作无法自己亲自完成,就将其交给“代理人”对象负责

Command模式

  • 一个类在进行工作时会调用自己或是其他类的方法,虽然调用结果会反应在对象的状态中,但并不会留下工作的历史记录。
  • 这时,如果我们有一个类,用来表示“请进行这项工作”的“命令”就会方便很多。每一项想做的工作就不再是“方法的调用”这种动态处理了,而是一个表示命令的类的实例,即–可以用“物”来标识。要想管理工作的历史记录,只需管理这些实例的集合即可,而且还可以随时再次执行过去的命令,或是将多个过去的命令整合为一个新命令并执行。
  • 在设计模式中,称这样的“命令”为Command模式

Q&A

  • 设计模式能够解决软件开发中的所有问题吗?

    • 不能,每个设计模式都是用于解决软件开发过程中遇到的问题,但是无论使用什么解决方法,都需要从整体权衡。设计模式并不能解决所有问题
  • 怎样才能选择出合适的设计模式呢?

    • 首先必须要明确知道自己的软件中存在什么样的问题。如果问题不够明确,是无法选择出合适的设计模式的。
    • 在学习设计模式时,我们要注意该模式“可以解决什么问题”
  • 所谓设计模式,其解决方法都是理所当然的,并不认为有值得关注和重新学习的价值。为什么设计模式很重要呢?

    • 在向经验丰富的开发人员介绍设计模式时,他们会认为这是“理所当然”的。当然是这样的,因为本来设计模式就是开发人员对反复遇到的问题总结出来的解决办法
    • 设计模式的重要性在于,可以帮助大家很快地掌握那些经验丰富的开发人员才具有的知识和经验
  • 设计模式很难背下来

    • 机械的背下来这些设计模式是没有意义的。重要的是在自己脑海中理解设计模式是怎样解决问题的
  • 设计模式对初级开发人员也有帮助吗?

    • 对于刚刚掌握了编程语言,并逐渐开始慢慢编写一些程序的初级开发人员来说,通过设计模式可以学习到“在进行面向对象编程时,应该注意什么
    • 例如,通过设计模式,我们可以学到本书中讲解过得可复用性,可替换性,接口(API),继承和委托,抽象化等
    • 此外,设计模式的知识也会对我们自己使用类库有所帮助。这是因为类库中的许多部分都与设计模式有关
  • 除了“设计模式”外,还常听到“模式”这个词,两者的意思是相同的吗?

    • 严格来讲,两者的意思是有区别的
    • 不论是在什么领域,给“在某种场景下重复发生的问题的解决办法”赋予名字,并整理而成的东西一般都称为“模式”
    • 设计模式,是适用于软件设计和开发领域的模式,它是模式中的一种
    • 不过,有时候在软件领域也会将“设计模式”简称为模式

参考书籍

  • 《深入设计模式》

  • https://refactoringguru.cn/design-patterns