0%

目录&索引

  • 条款01:视 c++ 为一个语言联邦。
    • c++高效编程守则视状况而变化,取决于你使用c++的哪个部分。
  • 条款02:尽量以const,enum,inline 替换 #define。
    • 对于单纯常量,最好以cosnt对象或enum替换#defines;
    • 对于形似函数的宏(macros),最好改用inline函数替换#define。
  • 条款03:尽可能使用const。
    • 将某些东西声明为const可帮助编译器侦测出错误用法。const可被施加于任何作用域内的对象、函数参数、函数返回类型、成员函数本体。
    • 编译器强制实施bitwise constness,但你编写程序时应该使用“概念上的常量性”(conceptual constness)。
    • 当const和non-const成员函数有着实质等价的实现时,令non-const版本调用const版本可避免代码重复。
  • 条款04:确定对象被使用前已先被初始化。
    • 为内置型对象进行手工初始化,因为c++不保证初始化它们。
    • 构造函数最好使用成员初值列(初始化列表),而不要在构造函数本体内使用赋值操作。初值列列出的成员变量,其排列次序应该和它们在class中的声明次序相同。
    • 为免除“跨编译单元之初始化次序”问题,请以local static对象替换non-local static对象。
  • 条款05:了解c++默默编写并调用哪些函数。
    • 编译器可以暗自为class创建默认构造函数、拷贝构造函数、赋值操作符以及析构函数。
  • 条款06:若不想使用编译器自动生成的函数,就该明确拒绝。
    • 为驳回编译器自动提供的机能,可将相应的成员函数声明为private并且不予实现。使用像Uncopyable这样的base class也是一种做法。
    • 附:在c++11以后,可以用delete修饰函数,这是一种更好的方法。
  • 条款07:为多态基类声明virtual析构函数。
    • polymorphic(带多态性质的)base classes应该声明一个virtual析构函数。如果class带有任何virtual函数,它就应该拥有一个virtual析构函数
    • classes的设计目的如果不是作为base classes使用,或不是为了具备多态性,就不应该声明virtual析构函数。
    • 纯虚的析构函数必须给出定义。
  • 条款08:别让异常逃离析构函数。
    • 析构函数绝对不要吐出异常。如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后吞下它们(不传播)或结束程序。
    • 如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么class应该提供一个普通函数(而非在析构函数中)执行该操作。
  • 条款09:绝不在构造和析构过程中调用virtual函数。
    • 在构造和析构期间不要调用virtual函数,因为这类调用从不下降至派生类(比起当前执行构造函数和析构函数的那层)。
  • 条款10:*令 operator= 返回一个reference to this。
    • 令 operator= 返回一个reference to *this。
  • 条款11:在 operator= 中处理“自我赋值”。
    • 确保当对象自我赋值时 operator= 有良好行为。其中技术包括比较“来源对象”和“”目标对象“的地址、精心周到的语句顺序、以及copy-and-swap。
    • 确定任何函数如果操作一个以上的对象,而其中多个对象是同一个对象时,其行为仍然正确。
  • 条款12:复制对象时勿忘其每一个部分。
    • copying函数应确保复制”对象内的所有成员变量“及”所有base class 成分“;
    • 不要尝试以某个copying函数实现另一个copying函数。应将共同技能放进第三个函数中,并由两个copying函数共同调用。
  • 条款13:以对象管理资源。
    • 为防止资源泄露,请使用RAII对象,它们在构造函数中获得资源并在析构函数中释放资源。
    • 两个常被使用的RAII classes 分别是tr1::shared_ptr和auto_ptr。前者通常是较佳选择 ,因为其copy行为比较直观。若选择auto_ptr,复制动作会使被复制物指向null。
  • 条款14:在资源管理类中小心copying行为。
    • 复制RAII对象必须一并复制它所管理的资源,所以资源的copying行为决定RAII对象的copying行为。
    • 普遍而常见的RAII class copying 行为是:抑制copying、施行引用计数法。不过其他行为也都可能被实现。
  • 条款15:在资源管理类中提供对原始资源的访问。
    • APIs往往要求访问原始资源,所以每一个RAII class 应该提供一个“取得其所管理之资源”的办法。
    • 对原始资源的访问可能经由显式转换(比如一个get成员函数)或隐式转换(重载operator A())。一般而言显式转换比较安全,但隐式转换对客户比较方便。
  • 条款16:成对使用new和delete时要采取相同形式。
    • 如果你在new表达式中使用[],必须在相应的delete表达式中也使用[]。如果你在new表达式中不使用[],一定不要在相应的delete表达式中使用[]。
  • 条款17:以独立语句将newed对象置入智能指针。
    • 以独立语句将newed对象存储于(置入)智能指针内。如果不这样做,一旦异常被抛出(在资源对象产生到置入资源管理对象之间,产生异常的话),有可能导致难以察觉的资源泄漏。
  • 条款18:让接口容易被正确使用,不易被误用。
    • 好的接口很容易被正确使用,不容易被误用。你应该在你的所有接口中努力达成这些性质。
    • “促使正确使用”的办法包括接口的一致性,以及与内置类型的行为兼容。
    • “阻止误用”的办法包括建立新类型、限制类型上的操作,束缚对象值,以及消除客户的资源管理责任。
    • tr1::shared_ptr支持定制型删除器。这可防范DLL问题,可被用来自动解除互斥锁(见条款14)等等。
  • 条款19:设计class犹如设计type。
    • class的设计就是type的设计。在定义一个新type之前,请确定你已经考虑过本条款覆盖的所有讨论主题。
  • 条款20:宁以pass-by-reference-to-const替换pass-by-value。
    • 尽量以 pass-by-reference-to-const 替换pass-by-value。前者通常比较高效,并可避免切割问题。
    • 以上规则并不适用于内置类型、STL的迭代器和函数对象。对它们而言,pass-by-value 往往比较适当。
  • 条款21:必须返回对象时,别妄想返回其reference。
    • 绝不要返回pointer或reference指向一个local stack对象,或返回reference指向一个heap-allocated对象。或返回pointer或reference指向一个local static对象而有可能同时需要多个这样的对象。条款4已经为“在单线程环境中合理返回reference 指向一个local static对象”提供了一份设计实例。
  • 条款22:将成员变量声明为private。
    • 切记将成员变量声明为private。这可赋予用户访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证,并提供class作者以充分的实现弹性。
    • protected并不比public更具封装性。
  • 条款23:宁以non-member&non-friend替换member函数。
    • 宁可拿non-member non-friend函数替换member函数。这样做可以增加封装性、包裹弹性和机能扩充性。
    • 附:大量的便利函数可以使用相同的命名空间而放在不同的头文件里,这样用户可以根据自己对功能的需求使用头文件,减少编译相依性。其中该有个核心的头文件,提供核心便利函数以及类的实现。
  • 条款24:若所有参数皆需类型转换,请为此采用non-member函数。
    • 如果你需要为某个函数的所有参数(包括this指针所指的哪个隐喻参数)进行类型转换,那么这个函数必须是个non-member。
    • 附:因为隐式转换需要匹配参数列,对于成员函数,第一个参数是*this,使得可能不能匹配转换。
  • 条款25:考虑写出一个不抛异常的swap函数。
    • 当std::swap对你的类型效率不高时,提供一个swap成员函数,并确定这个函数不抛出异常。
    • 如果你提供一个member swap,也该提供一个non-member swap用来调用前者。对于classes(而非templates),也请全特化std::swap。
    • 调用swap时应针对std::swap使用using声明式,然后调用swap并且不带任何“命名空间资格修饰”。
    • 为“用户定义类型”进行std templates全特化是好的,但千万不要尝试在std内加入某些对std而言全新的东西。
  • 条款26:尽可能延后变量定义式的出现时间。
    • 尽可能延后变量定义式的出现。这样做可增加程序的清晰度并改善程序效率。
  • 条款27:尽量少做转型动作。
    • 如果可以,尽量避免转型,特别是在注重效率的代码中避免dynamic_casts。如果有个设计需要转型动作,试着发展无需转型的替代设计。
    • 如果转型是必要的,试着将它隐藏于某个函数背后。客户随后可以调用该函数,而不需要将转型放进他们自己的代码内。
    • 宁可使用c++-style(新式)转型,不要使用旧式转型。前者很容易辨识出来,而且也比较有着分门别类的职掌。
  • 条款28:避免返回handles指向对象内部成分。
    • 避免返回handles(包括reference、指针、迭代器)指向对象内部。遵守这个条款可增加封装性,帮助const成员函数的行为像个const,并将发生“虚吊号码牌”的可能性降至最低。
  • 条款29:为“异常安全”而努力是值得的。
    • 异常安全函数即使发生异常也不会泄漏资源或允许任何数据结构败坏。这样的函数区分为三种可能的保证:基本型、强烈型、不抛异常型。
    • “强烈保证”往往能够以copy-and-swap实现出来,但“强烈保证”并非对所有函数都可实现或具备实现意义。
    • 函数提供的“异常安全保证”通常最高只等于其所调用之各个函数的“异常安全保证”中的最弱者。
  • 条款30:透彻了解inlining的里里外外。
    • 将大多数inlining限制在小型、被频繁调用的函数身上。这可使日后的调试过程和二进制升级更容易,也可使潜在的代码膨胀问题最小化,使程序的速度提升机会最大化。
    • 不要只因为function templates出现在头文件,就将它们声明为inline。
  • 条款31:将文件间的编译依存关系降至最低。
    • 支持“编译依存性最小化”的一般构想是:相依于声明式,而不要相依于定义式。基于此构想的两个手段是Handle classes和Interface classes。
    • 程序库头文件应该以“完全且仅有声明式”的形式存在。这种做法不论是否涉及templates都适用。
  • 条款32:确定你的public继承塑膜出is-a关系。
    • “public 继承”意味着 is-a。适用于base classes身上的每一件事情一定也适用于derived classes身上,因为每一个derived class对象也都是一个base class对象。
  • 条款33:避免遮掩继承而来的名称。
    • derived classes内的名称会遮掩base classes内的名称。在public继承下从来没有人希望如此。
    • 为了让被遮掩的名称再见天日,可使用using声明式或转交函数。
  • 条款34:区分接口继承和实现继承。
    • 接口继承和实现继承不同。在public继承之下,derived classes总是继承base class的接口。
    • pure virtual函数只具体指定接口继承。(附:有强烈的特异性,必须重新实现。并且纯虚函数的定义实现可以用来充当缺省版本)
    • 简朴的(非纯)impure virtual函数具体指定接口继承及缺省实现继承。(附:根据自己的特殊情况看需不需要覆写,如果没必要可以用基类的版本,但这个缺省的功能可能会产生一些危险。)
    • non-virtual函数具体指定接口继承以及强制性实现继承。(附:强烈的共性,派生类只管继承不覆写)
  • 条款35:考虑virtual函数以外的其他选择。
    • virtual函数的替代方案包括NVI手法及Strategy设计模式的多种形式。NVI手法自身是一个特殊形式的Template Method设计模式。
    • 将机能从成员函数移到class外部函数,带来的一个缺点是,非成员函数无法访问class的non-public成员。
    • tr1::function对象的行为就像一般函数指针。这样的对象可接纳“与给定之目标签名式兼容”的所有可调用物。
  • 条款36:绝不重新定义继承而来的non-virtual函数。
    • 绝不重新定义继承而来的non-virtual函数。
  • 条款37:绝不重新定义继承而来的缺省参数值。
    • 绝对不要重新定义一个继承而来的缺省参数值,因为缺省参数值都是静态绑定,而virtual函数——你唯一应该覆写的东西——却是动态绑定。
    • 附:注意仅仅是参数值,而非整个函数。函数是可以重新定义的,并且当使用了一个基类指针时,可以视为带了默认参数。
  • 条款38:通过复合塑模出has-a或“根据某物实现出”。
    • 复合(composition)的意义和public继承完全不同。
    • 在应用域(application domain),复合意味has-a;在实现域(implementation domain),复合意味is-implemented-in-terms-of(根据某物实现出)。
  • 条款39:明智而审慎地使用private继承。
    • private继承意味着is-implemented-in-terms-of(根据某物实现出)。它通常比复合(composition)的级别低。但是当derived class需要访问protected base class的成员,或需要重新定义继承而来的virtual函数时,使用private是合理的。
    • 和复合(composition)不同,private继承可以造成empty base最优化。这对致力于“对象占用空间最小化”的程序库开发者而言,可能很重要。
  • 条款40:明智而审慎地使用多继承。
    • 多重继承比单一继承复杂。它可能导致新的歧义性、以及对virtual继承的需要。
    • virtual继承会增加大小、速度、初始化(及赋值)复杂度等等成本。如果virtual base classes不带任何数据,将是最具实用价值的情况。
    • 多重继承的确有正当用途。其中一个情节涉及“public继承某个Interface class”和“private继承某个协助实现的class”的两相组合。
  • 条款41:了解隐式接口和编译期多态。
    • classes和templates都支持接口和多态。
    • 对classes而言接口是显式的,以函数签名为中心。多态则是通过virtual函数发生于运行期。
    • 对template参数而言,接口是隐式的,奠基于有效表达式。多态则是通过template具现化和函数重载解析发生于编译期。
  • 条款42:了解typename的双重意义。
    • 声明template参数时,前缀关键字class和typename可互换。
    • 请使用关键字typename标识嵌套从属类型名称;但不得在base class lists(基类列)或member initialization list(成员初值列)内以它作为base class修饰符。
  • 条款43:学习处理模板化基类内的名称。
    • 可在 derived class template 内通过 “this->” 指涉 base class template 内的成员名称,或藉由一个明白写出的 “base class 资格修饰符” 完成。
    • 附:以及using声明
  • 条款44:将与参数无关的代码抽离templates。
    • Templates 生成多个 classes 和多个 functions,所以任何 template 代码都不该与某个造成膨胀的 template 参数产生相依关系。
    • 因非类型模板参数(non-type template parameters)而造成的代码膨胀,往往可以消除,做法是以函数参数或 class 成员变量替换 template 参数。
    • 因类型参数(type parameters)而造成的代码膨胀,往往可以降低,做法是让带有完全相同二进制表述(binary representations)的具现类型(instantiation types)共享实现码。
  • 条款45:运用成员函数模板接受所有兼容类型。
    • 请使用member function templates(成员函数模板)生成“可接受所有兼容类型”的函数。
    • 如果你声明member templates用于“泛化copy构造”或“泛化assignment操作”,你还是需要声明正常的copy构造函数和copy assignment操作符。
  • 条款46:需要类型转换时请为模板定义非成员函数。
    • 当我们编写一个 class template,而它所提供之“与此 template 相关的”函数支持“所有参数隐式类型转换”时,请将那些函数定义为 “class template 内部的 friend 函数”。
  • 条款47:请使用traits classes 表现类型信息。
    • Traits classes使得“类型相关信息”在编译期可用。它们以templates和“templates特化”完成实现。
    • 整合重载技术后,traits classes有可能在编译期对类型执行if…else测试。
  • 条款48:认识template元编程。
    • Template metaprogramming(TMP,模板元编程)可将工作由运行期移到编译期,因而得以实现早期错误侦测和更高的执行效率。
    • TMP可被用来生成“基于政策选择组合”(based on combinations of policy choices)的客户定制代码,也可用来避免生成对某些特殊类型并不适合的代码。
  • 条款49:了解new-handler的行为。
    • set_new_handle 允许用户指定一个函数,在内存分配无法获得满足时被调用
    • nothrow new 是一个颇为局限的工具,因为它只适用于内存分配;后继的构造函数调用还是有可能抛出异常
  • 条款50:了解new和delete的合理替换时机。
    • 有许多理由需要写个自定义的 new 和 delete,包括改善效能、对 heap 运用错误进行调试、收集 heap 使用信息。
  • 条款51:编写new和delete时需固守常规。
    • operator new 应该内含一个无穷循环,并在其中尝试分配内存,如果它无法满足内存需求,就该调用 new-handler。它也应该有能力处理 0 bytes 申请。class 专属版本的还应该处理“比正确大小更大的(错误)申请”
    • operator delete 应该在收到 null 指针时不做任何事。class专属版本则还应该处理“比正确大小更大的(错误)申请”
  • 条款52:写了placement new也要写placement delete。
    • 当你写一个 placement operator new,请确定也写出了对应的 placement operator delete。如果没有这样做,就可能造成隐蔽的内存泄漏。
    • 当你声明 placement new 和 placement delete ,请确定不要无意识(非故意)地遮掩了它们的正常版本。
  • 条款53:不要轻忽编译器的警告。
    • 严肃对待编译器发出的警告信息。努力在你的编译器最高警告级别下争取”无任何警告“。
    • 不要过度依赖编译器的报警能力,因为不同编译器对待事情的态度并不相同。一段有警告的代码,移植到另一个编译器上,可能没有任何警告。
  • 条款54:让自己熟悉包括TR1在内的标准程序库。
    • C++标准程序库的主要机能由STL、iostreams、locales组成。并包含C99标准程序库。
    • TR1添加了智能指针、一般化函数指针、hash-based容器、正则表达式以及另外10个组件的支持。
    • TR1自身只是一份规范,为获得TR1提供的好处,你需要一份实物。一个好的实物来源是Boost。
  • 条款55:让自己熟悉Boost。
    • Boost 是一个社群,也是一个网站。致力于免费,源码开放,同僚复审的 C++ 程序库开发。 Boost 在 C++ 标准化过程中扮演深具影响力的角色。
    • Boost 提供许多 TR1 组件实现品,以及其他许多程序库。

条款01

视 c++ 为一个语言联邦。

c++ 已经是个多重范型编程语言,是个同时支持过程形式、面向对象形式、函数形式、泛型形式、元编程形式的语言。要如何理解这样一个语言呢?

最简单的方法是将c++视为一个由相关语言组成的联邦而非单一语言。在其某个次语言种,各种守则与通例都倾向简单、直观易懂、并且容易记住。然而当你从一个次语言移往另一个次语言,守则可能改变。为了理解c++,你必须认识其主要的次语言,总共只有四个:

  • C
  • Object-Oriented C++
  • Template C++
  • STL

比如,

  • 对于内置类型(C-like)而言,pass-by-value通常比pass-by-reference更高效。
  • 在Object-Oriented C++,由于用户自定义构造函数和析构函数的存在,pass-by-reference-to-const往往更好,Template C++也是如此。
  • 然而一旦跨入STL,迭代器和函数对象都是在C指针之上塑造出来的,pass-by-value守则再次适用。

记住:

c++高效编程守则视状况而变化,取决于你使用c++的哪个部分。

条款02

尽量以const,enum,inline 替换 #define。

这个条款事实上为“宁可以编译器替换预处理器”,因为或许 #define 不被视为语言的一部分。

当你作出:#define myconst 1.6时,记号名称myconst也许从未被编译器看见,也许在编译器开始处理源码之前它就被处理器移走了。于是记号名称myconst有可能没进入记号表内。于是当你运用此常量但获得一个编译错误信息时,可能会带来困惑,因为这个错误信息也许会提到1.6而非myconst(在程序里使用myconst,报错为1.6,因为预处理器会将myconst替换为1.6)。如果mycosnt被定义在一个非你所写的头文件内,你肯定对1.6以及它来自于何处毫无概念,于是你将因为追踪它而浪费时间。这个问题也可能出现在记号式调试器,原因仍是:你所使用的名称可能并未进入记号表。

解决之道是使用const:const double myconst = 1.6;作为一个语言常量,myconst肯定会被编译器看到,当然就会进入记号表内。

以常量替换#define时,有两种特殊情况:

  • 定义常量指针:由于常量定义式通常放在头文件内,因此有必要将指针(而不只是指针所指之物)声明为const。例如若要在头文件内定义一个常量的char*-base字符串,必须写const两次:

    • const char* const authorName = "Scott Meyers";
    • 第一个const说明指向常量字符串,第二个const说明指针本身也是常量(不可改)
    • string对象通常更合宜,定义成这样往往更好:const std::string authorName("Scott Meyers");
  • class专属常量:#define 并不重视作用域,不能提供任何封装性。为了将常量的作用域限制于class内,必须让它称为class的一个成员。而为确保此常量至多只有一份实体(不允许不同实例使用不同的值初始化该const变量),必须让它称为一个static成员:

    • class GP{private: static const int num = 5;};然而这是num的声明式而非定义式。通常c++要求对使用的任何东西提供一个定义式,但如果它是个class专属常量又是static且为整数类型,则需特殊处理。只要不取它们的地址,可以声明并使用而无须提供定义式。但如果取某个class的专属常量的地址,或编译器坚持要看到定义式,则必须提供定义:
    • const int GP::num; 不必提供数值,因为声明式已经提供初值。
    • 因此个人感觉更建议的方式是,在类中声明(在头文件中)但不给予初值,在类外定义时(在实现文件种)再给予初值(这样不会忘记定义式),就不会出错。实际上,旧式编译器不支持在声明时给static成员赋初值。
  • 针对第二点,如果在class编译期间需要一个class常量值,比如类中还定义了一个数组int scores[num];,这时如果不允许num在声明时赋值,就可以采用”the enum hack”补偿做法,理论基础是:一个属于枚举类型的数值可权充int被使用。

    1
    2
    3
    4
    5
    6
    class GP
    {
    private:
    enum{num = 5};//令num成为5的一个记号名称
    int scores[num];
    }
    • enum hack的行为某方面说比较像#define 而不像const,有时候这正是想要的。例如取const的地址是合法的,而取enum的地址和#define的地址是不合法的。如果不想让别人获得一个指针或引用指向你的某个整数常量,enum可以帮助你实现这个约束。
    • 此外优秀的编译器不会为”整数型const对象“设定另外的存储空间(除非创建指针或引用指向该对象),但不够优秀的编译器可能会创建对象。enum和#define就绝不会导致非必要的内存分配。

另一个常见的#define误用情况是以它实现宏(macros)。宏看起来像函数,但不会招致函数调用带来的额外开销,下面这个宏夹带着宏实参,调用函数f:

1
#define CALL_WITH_MAX(a,b) f((a)>(b)?(a):(b))//宏名没有类型,也没有参数类型。

无论何时当你写出这种宏,必须记住为宏中的所有实参加上小括号,否则某些人在表达式中调用这个宏时可能会遭遇麻烦。

1
2
3
4
5
6
7
8
9
加括号是为了处理表达式参数(即宏的参数可能是个算法表达式)时不出错,因为宏替换就是文本替换,所以如果有以下情况:
#define COM(A,B) (A)*(B)
那么COM(6+5,3)这个调用会怎么替换呢?它会换成这样:
(6+5)*(3)
显然这是和COM宏的意图一致的,但是如是去掉了定义中括号,即写成这样:
#define COM(A,B) A*B
那么COM(6+5,3)这个调用会怎么替换呢?它就会换成这样:
6+5*3
这样显然就和宏的意图不符合了。

纵使加上小括号,也会出现不可思议的事情:

1
2
3
int a = 5, b = 0;
CALL_WITH_MAX(++a,b);//a被累加两次
CALL_WITH_MAX(++a,b+10);//a被累加一次

在这里,调用f之前,a的递增次数竟然取决于它和谁比较。因为宏本质是替换,++a把(a)替换了,就导致比较时累加一次,如果++a更大,则传入f的参数是++a,又累加一次。

幸运的是,只要写出template inline函数,就可以获得宏带来的效率以及一般函数的所有可预料行为和类型安全性:

1
2
3
4
5
template<typename T>
inline void callWithMax(const T& a, const T& b)
{
f(a>b?a:b);
}

有了const、enum、inline,我们对预处理器(特别是#define)的需求降低了,但并非完全消除。#include仍然是必需品,而#ifdef/#ifndef也继续扮演控制编译的重要角色。

记住:

  • 对于单纯常量,最好以cosnt对象或enum替换#defines;
  • 对于形似函数的宏(macros),最好改用inline函数替换#define。

条款03

尽可能使用const。

const允许你指定一个语义约束,而编译器会强制实施这项约束。它允许你高速编译器和其他程序员某值应该保持不变。只要某值保持不变是事实,你就该说出来,因为这可以获得编译器的帮助。

如果const出现在星号左边,表示被指物是常量;如果出现在星号右边,表示指针自身是常量;如果出现在两边,表示二者都是常量。

1
2
3
4
5
char greeting[] = "hello";
char *p = greeting;//不是常量指针、不指向常量数据
const char *p = greeting;//不是常量指针,指向常量数据
char* const p = greeting;//常量指针,不指向常量数据
const char* const p = greeting;//常量指针、指向常量数据

如果被指物(数据)是常量,const可以在类型之前也可以在类型之后:

1
2
void f1(const Widget *pw);
void f2(Widget const *pw);//两种写法都一样

在STL内,有const_iterator和iterator两种迭代器:

  • const iterator iter;则iter作用像一个T* const,表示iter本身不可改,所指之物可改
  • const_iterator citer;则citer作用像一个const T*,表示所指之物不可改,本身可改

const最具威力的用法是面对函数声明时的应用,可以和函数返回值、参数、函数自身(作为成员函数)产生关联。

  • 返回值:如const int f1,使得函数返回后的对象为右值,不可赋值,避免拿返回值再做赋值(f1() = 5这样的事情)。const可以预防这些没有意义的赋值动作。

  • 参数:即表示传入参数不可改动,除非有需要改动,否则将它们声明为const。这样可以避免“想要输入‘==’却输入成‘=’”的错误。

  • 自身(const成员函数):是为了确认该成员函数可以作用于const对象身上。

    • 1.它们使得class接口比较容易理解,能够得知哪个函数可以改动对象内容而哪个不行。
    • 2.它们使“操作const对象(常对象)”成为可能,常对象只能调用const成员。
    • 3.两个成员函数如果只是常量性不同(函数后有无const),也可以被重载。常对象调用const成员函数,而非常对象调用non-const成员函数。
    • 附:真实程序中常对象大多用于以passed by pointer-to-const或passed by reference-to-const形式函数传参,如void print(const A& x);一般都用于读取值。

对于const成员函数,有两个流行的概念:bitwise constness(又称physical constness)和logcial constness。

  • bitwise constness认为成员函数不更改对象内的任何一个bit,它正是c++对常量性的定义,因此const成员函数不可以改变对象内任何non-static成员变量。
    • 然而,如果一个类成员为non-const的指针(而非其所指之物),那么一个const成员函数可以仅仅返回该指针而不作任何改变(使得编译器通过)。这也就是说,可以通过常对象调用该const成员函数(返回non-const成员),然后在外部获取这个指针,再修改指针所指之物是合法的。
    • 这其中没有任何错误:创建一个常对象并设某值,而且只对它调用const成员函数。但终究改变了它的值。
  • 这种情况导出logical constness:一个const成员函数可以修改它所处理的对象内的某些bits,但只有在客户端侦测不出的情况下才得如此。

可以用mutable关键字释放掉non-static成员变量的bitwise constness约束。


当const成员函数和non-const成员函数有实质等价的实现时,都去实现这两个函数就会使得代码重复、膨胀、编译时间、维护等问题。此时真正应该作的是实现const成员函数并让non-const成员函数调用它。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
class text
{
public:
const char& func(...) const
{
...
return text;
}
char& func(...)
{
return const_cast<char&>(static_cast<const text&>(*this).func(...));
}
}

这份代码有两个转型动作:

  • 要让func调用func const,而不是调用自己(会造成无穷尽的递归)。因此必须明确指出是func const,所以这里将*this从原始类型text&转型为const text&,再调用func函数,则此时(是常对象)调用的是func const。
    • 将non-const对象转为const对象强迫进行了一次安全转型(是安全的),所以需要使用static_cast。
  • 使用const_cast将const func从返回值移除const。

const版本调用non-const版本并不是该作的事,因为const成员函数承诺绝不改变对象的逻辑状态,而non-const成员函数却没有这般承诺。这就是为什么这里能用static_cast作用于*this:这里并不存在const相关危险。

记住:

  • 将某些东西声明为const可帮助编译器侦测出错误用法。const可被施加于任何作用域内的对象、函数参数、函数返回类型、成员函数本体。
  • 编译器强制实施bitwise constness,但你编写程序时应该使用“概念上的常量性”(conceptual constness)。
  • 当const和non-const成员函数有着实质等价的实现时,令non-const版本调用const版本可避免代码重复。

条款04

确定对象被使用前已先被初始化。

读取未初始化我值,有时可能让程序终止运行,更可能的情况是读入一些“半随机”bits。最佳的处理办法就是:永远在使用对象之前先将它初始化。对于无任何成员的内置类型,必须手工完成。而内置类型以外的任何其他东西,都交给构造函数来初始化,其规则是:确保每一个构造函数都将对象的每一个成员初始化。

这个规则很简单,但重要的是别混淆了赋值和初始化。如果在类中先声明了成员,然后在构造函数的函数体赋值,则本质上这些成员变量执行的是声明-赋值,先调用默认的构造函数,然后执行赋值构造函数(两步,效率低)。更好的写法是用初始化列表替换赋值动作,对大多数类型而言,这样效率更高(一步,直接调用构造函数),有时甚至高效得多。对于内置类型对象,初始化和赋值成本相同,但为了一致性最好也通过初始化列表来初始化。

1
2
A::A(name):Name(name),Addr(){}
//初始化列表:Name调用带参构造函数,Addr调用默认构造函数

规定总是在初始化列表中列出所有成员变量。那些无需初值的变量使用一个()即可,以免还得记住哪些成员变量不需要初值。

另外,如果成员变量是const或reference的,它们就必须使用初始化列表。因而很多时候最简单的做法就是总使用初始化列表。

在初始化列表中,成员的初始化次序与初始化列表中的次序无关,真的的次序是成员变量在类中被声明的次序。这导致后声明的变量可以使用先声明的变量来初始化,反之不行。因此,也最好按声明的次序在初始化列表里初始化。

初始化列表除了初始化成员变量,还初始化继承的基类,一般基类总是在最前面。现在,还剩下non-local static对象需要讨论。


所谓static对象,其寿命从被构造出来直到程序结束为止,因此stack和heap-base对象都被排除。这种对象包括global对象、定义于namespace作用域内的对象、在class内、在函数内、以及在file作用域内被声明为static的对象。函数内的static对象称为local static对象(因为它们对函数而言是local的),其他对象称为non-local static对象。程序结束时static对象自动销毁,也即它们的析构函数会在main()结束时被自动调用。

所谓编译单元,是指产出单一目标文件的那些源码,基本上它是单一源码文件加上其所含入的头文件。

我们关心的问题涉及至少两个源码文件。如果某编译单元内的某个non-local static对象的初始化动作使用了另一个编译单元内的某个non-local static对象,它所用到的这个对象可能尚未被初始化。

下面是一个实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//文件系统,应是全局的
class FileSystem//来自你的程序库
{
public:
...
std::size_t numDisks() cosnt;
...
};
extern FileSystem tfs;//预备给客户使用的对象

//客户的处理class
class Directory//来自程序库客户建立
{
public:
Directory(params);
};
Directory::Directory(params)
{
...
std::size_t disk = tfs.numDisks();//使用tfs对象
...
}

Directory tempDir(params);//创建一个对象

现在,除非tfs在tempDir之前被初始化,否则tempDir的构造函数会用到尚未初始化的tfs。但tfs和tempDir是不同的人在不同的时间于不同的源码文件建立起来的,它们是定义于不同编译单元内的non-local static对象,如何能确定tfs先初始化呢?

c++对此是非常困难,根本无解的。

一个小小的设计便可以完全消除这个问题,唯一需要做的是将每个non-local static对象搬到自己的专属函数内(该对象在此函数内被声明为static)。这些函数返回一个reference指向它所包含的对象。然后用户调用这些函数,而不直接指涉这些对象。换句话说,non-local static对象被local static对象替换了。这便是Singleton模式的一个常见实现手法(《Design Patterns》)

这个手法的基础在于:c++保证函数内的local static会在“该函数被调用期间”以及“首次遇上该对象定义式”时被初始化。所以如果你以“函数调用”(返回reference指向local static对象)替换“直接访问non-local static对象”,就获得了保证:保证你所获得的哪个reference将指向一个历经初始化的对象。更棒的是,如果你从未调用non-local static对象的仿真函数,就不会引发构造和析构成本(直接访问需要先创建对象而不管之后会不会用到,这是有成本的)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class FileSystem{};//同前
FileSystem& tfs()
{
static FileSystem fs;//定义并初始化一个local static对象,并返回引用
return fs;
}

class Directory{};//同前
Directory::Directory(params)
{
...
std::size_t disk = tfs().numDisks();//改为tfs()
...
}
Directory& tempDir()
{
static Directory tempD;
return tempD;
}

它们使用函数返回的“指向static对象”的引用,而不再使用static对象自身。

这种结构下的reference-returning函数往往十分单纯:第一行定义并初始化一个local static对象,第二行返回它。

在多线程系统中仍带有不确定性(不论是local或者non-local)。处理的做法是:在程序的单线程启动阶段手工调用所有reference-returning函数,这可消除与初始化有关的race conditions。

记住:

  • 为内置型对象进行手工初始化,因为c++不保证初始化它们。
  • 构造函数最好使用成员初值列(初始化列表),而不要在构造函数本体内使用赋值操作。初值列列出的成员变量,其排列次序应该和它们在class中的声明次序相同。
  • 为免除“跨编译单元之初始化次序”问题,请以local static对象替换non-local static对象。

条款05

了解c++默默编写并调用哪些函数。

如果没有声明拷贝构造、赋值构造、析构函数,编译器就会为它自动声明编译器版本的函数。此外如果没有声明任何构造函数,编译器也会自动声明一个默认构造函数。这些函数都是public且inline的。唯有当这些函数被需要(调用),它们才会被编译器创建出来。

「具体参考语法记录博客」

记住:

编译器可以暗自为class创建默认构造函数、拷贝构造函数、赋值操作符以及析构函数。

条款06

若不想使用编译器自动生成的函数,就该明确拒绝。

所有编译器产出的函数都是public。为阻止这些函数被创建出来,你得自行声明它们,但这里并没有什么需求使你必须将它们声明为public。因此你可以将构造函数、析构函数声明为private。藉由明确声明一个成员函数,你阻止了编译器暗自创建其专属版本;而令这些函数为private,使得你成功阻止人们调用它。

而成员函数和friend函数还是可以调用private函数,因此更进一步的做法是,在private声明而不去定义,这样即使调用,也会得到错误。

一般的做法是,将阻止构造的动作设计在基类,然后继承:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Uncopyable
{
protected:
Uncopyable(){}
~Uncopyable(){}
private:
Uncopyable(const Uncopyable&);//声明,没有函数体,且无参数名称(无意义)
Uncopyable& operator=(const Uncopyable&);
}

class anyclass:private Uncopyable//不一定要public继承,个人理解为都可以,因为没有其他数据
{
...
}

这种情况下,anyclass的拷贝构造和赋值构造都不会自动生成,因为基类已经声明了。

记住:

为驳回编译器自动提供的机能,可将相应的成员函数声明为private并且不予实现。使用像Uncopyable这样的base class也是一种做法。

附:在c++11以后,可以用delete修饰函数,这是一种更好的方法。

条款07

为多态基类声明virtual析构函数。

当一个基类指针指向一个派生类对象,而基类实现中析构函数是non-virtual的,则此时若进行销毁(delete),实际上只会调用基类的析构函数而不会调用派生类的析构函数(因为编译器发现这是基类指针),导致内存释放不完全,因为派生类对象的成员仍然存活,只有基类成员被消除。

消除这个问题的做法是:给基类一个virtual析构函数。这明确告诉编译器这个类是多态的,要销毁对象时要在运行期根据所指对象来调用析构函数(通过虚表)。

  • 任何class只要带有virtual函数,都几乎确定应该也有一个virtual析构函数;
  • 如果class不含virtual函数,通常表明它并不意图被用作一个基类。
  • 当class不企图被当作基类时,往往没有virtual函数,因为一旦有virtual函数,就需要一个虚函数指针指向虚表,这是额外的开销。
  • 抽象类总是希望当作基类,因此往往析构函数写为(纯虚函数):virtual ~A() = 0;除此之外,必须为纯虚的析构函数提供一份定义:A::A(){}。因为在析构时,编译器往往从最深层的析构函数开始调用,逐步调用每一个基类的析构函数,如果该析构函数没有定义,就会出错。

不是所有的基类都是为了多态用途。

记住:

  • polymorphic(带多态性质的)base classes应该声明一个virtual析构函数。如果class带有任何virtual函数,它就应该拥有一个virtual析构函数
  • classes的设计目的如果不是作为base classes使用,或不是为了具备多态性,就不应该声明virtual析构函数。
  • 纯虚的析构函数必须给出定义。

条款08

别让异常逃离析构函数。

不要在析构函数中抛出异常,原因是C++异常机制不能同时处理两个或两个以上的异常。多个异常同时存在的情况下,程序若不结束,会导致不明确行为。如下代码:

1
2
3
4
5
6
7
class Widget{
public:
~Widget(){...} //假设这个可能吐出一个异常
};
void dosomething(){
vector<Widget> v;
} //v在这里被自动销毁

函数dosomething运行结束后,最为栈对象的vector v将被销毁,它同时也有责任销毁其内含的所有Widgets。假设v内含十个Widgets,而在析构第一个元素期间,有个异常被抛出。其他九个widgets还是应该被销毁(否则他们保存的任何资源都会发生泄漏),因此v应该调用它们各个析构函数。但假设在那些调用期间,第二个widget析构函数又抛出异常,C++无法同时处理两个或多个异常,多个异常同时存在的情况下,程序若不结束,会导致不明确行为。

如果析构函数必须执行一个动作,而该动作可能会在失败时抛出异常,该怎么办?举个例子,假设你使用一个class负责数据库连接:

1
2
3
4
5
6
class DBConnection { 
public:
   ...
   static DBConnection create(); //返回DBConnection对象;为求简化暂略参数
   void close(); //关闭联机;失败则抛出异常。
};

为确保客户不忘记在DBConnection对象身上调用close(),一个合理的想法是创建一个用来管理DBConection资源的class,并在其析构函数中调用close。这就是著名的以对象管理资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class DBConn { //这个class用来管理DBConnection对象 
public:
   ...
  DBConn(const DBConnection& db){
this->db=db;
}
  ~DBConn() //确保数据库连接总是会被关闭
  {
   db.close();
  }
  
private:
   DBConnection db;
};

调用close成功,一切都美好。但如果该调用导致异常,DBConn析构函数会传播该异常,也就是允许它离开这个析构函数。那会造成问题,解决办法如下:

1
2
3
4
5
6
7
8
9
//方法一:结束程序
DBConn::~DBconn(){
try {
db.close(); }
catch(...){
//制作运转记录,记下对close的调用失败
std::abort();
}
}

如果程序遭遇一个“于析构期间发生的错误”后无法继续执行,“强制结束程序”是个合理选项,毕竟它可以阻止异常从析构函数传播出去(那会导致不明确的行为)。也就是说调用abort可以抢先制“不明确行为”于死地。

1
2
3
4
5
6
7
//方法二:吞下异常
DBConn::~DBConn{
try{ db.close(); }
catch(...) {
//制作运转记录,记下对close的调用失败
}
}

一般而言,将异常吞掉是个坏主意,因为它压制了“某些动作失败”的重要信息。然而有时候吞下异常也比负担“草率结束程序”或“不明确行为带来的风险”好。为了让这成为一个可行方案,程序必须能够继续可靠的执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//方法三:重新设计DBConn接口,使其客户有机会对可能出现的异常作出反应 
class DBConn {
public:
...
void close() //供客户使用的新函数
{
db.close();
closed = true;
}
~DBConn(){
if(!closed) {
try { //关闭连接(如果客户不调用DBConn::close)
db.close();
}
catch(...) { //如果关闭动作失败,记录下来并结束程序或吞下异常。
制作运转记录,记下对close的调用失败;//然后吞下异常或结束程序
...
}
}
}
private:
DBConnection db;
bool closed;
};

我们可以给DBConn添加一个close函数,赋予客户一个机会可以处理“因该操作而发生的异常”。把调用close的责任从DBConn析构函数手上移到DBConn客户手中,你也许会认为它违反了“让接口容易被正确使用”的忠告。

实际上这污名并不成立。如果某个操作可能在失败的时候抛出异常,而又存在某种需要必须处理该异常,那么这个异常必须来自析构函数以外的某个函数。因为析构函数吐出异常就是危险,总会带来“过早结束程序”或“发生不明确行为”的风险。

由客户自己调用close并不会对他们带来负担,而是给他们一个处理错误的机会。如果他们不认为这个机会有用(或许他们坚信不会有错误发生),可能忽略它,依赖DBConn析构函数去调用close。

记住:

  • 析构函数绝对不要吐出异常。如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后吞下它们(不传播)或结束程序。
  • 如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么class应该提供一个普通函数(而非在析构函数中)执行该操作。

条款09

绝不在构造和析构过程中调用virtual函数

之所以不要在构造函数和析构函数起点调用virtual函数,是因为这种调用并不会带来预期的结果。

举个例子,假设有这样一个class继承体系,用来模拟股市的买进、卖出的订单等。在这样的过程中,一定要经过审计,因此每当创建一个交易对象时,在审计日志(audit log)中也需要创建一笔适当的记录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Transaction {         //所有交易的base class
public:
Transaction();
virtual void logTransaction() const = 0; //做出一份因为类型不同而不同的日志记录,目前是一个纯虚函数
...
};

Transaction::Transaction() //base class的构造函数的实现
{
...
logTransaction(); //最后的动作是对这笔交易进行记录
}

class BuyTransaction: public Transaction { //derived class
public:
virtual void logTransaction() const; //对这种类型的交易进行记录(log)
...
};

class SellTransaction: public Transaction { //derived class
public:
virtual void logTransaction() const; //对这种类型的交易进行记录(log)
...
};

当我们执行如下语句时:

1
BuyTransaction b;

无疑会有一个BuyTransaction构造函数被调用,但是,首先Transaction构造函数一定会更早的被调用。因为derived class对象内的base class成分会在derived class自身成分被构造之前先完成构造。

然而,Transaction构造函数中的virtual函数logTransaction却会引发问题。因为此时所调用的logTransaction是Transaction内的版本,而不是BuyTransaction内的版本——即使目前即将创建的对象是BuyTransaction。也就是说:base class构造期间,virtual函数绝不会下降到derived classes阶层。换种非正式的说法:在base class构造期间,virtual函数不是virtual函数。

  • 这是因为在base class构造函数执行时derived class的成员变量尚未初始化。如果此期间调用的virtual函数下降至derived class阶层,要知道derived class的函数几乎必然取用local成员变量,而那些成员变量尚未初始化
  • 更根本的原因在于:在derived class对象的base class构造期间,对象的类型是base class而不是derived class。不止virtual函数会被编译器解析至(resolve to)base class,若使用运行期间类型信息,也会把对象视为base class。

在上面的例子中,当Transaction构造函数正在执行起来,打算初始化“BuyTransaction对象内的base class成分”时,该对象的类型是Transaction。而这个对象内的“BuyTransaction专属成分”尚未被初始化。因此在面对它们时,最安全的做法就是视它们不存在。对象在derived class构造函数开始执行之前不会成为一个derived class对象。

相同的道理同样适用于析构函数。一旦derived class析构函数开始执行,对象内的derived class成员变量便呈现出未定义值。因此,C++将其视为仿佛不存在。进入base class析构函数后对象就变成了一个base class对象。


因此,在上面的例子中,Transaction构造函数直接调用一个virtual函数,这很明显就违反了该条款的内容。这就导致了一个问题:因为logTransaction函数在Transaction内是一个pure virtual(纯虚函数)。除非它被定义了,否则程序无法连接,因为连接器找不到必要的Transaction::logTransaction实现代码。

但是,侦测“构造函数或析构函数运行期间是否调用virtual函数”并不简单。一般来说,如果Transaction有多个构造函数,每个都需要执行某些相同的工作,那么避免代码重复的一个优秀做法就是将共同的初始化代码(包括对logTransaction的调用)都放到一个初始化函数如init内:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Transaction {
public:
Transaction() //调用non-virtual(init()是non-virtual)
{ init(); }
virtual void logTransaction() const = 0;
...
private:
void init()
{
...
logTransaction(); //调用了virtual!!
}
};

上面的这段代码,和早期的版本是一样的,但是却有更深层次的危害,因为这样并不会引起编译器和连接器的报错。此时,由于logTransaction是Transaction的一个pure virtual函数,当pure virtual函数被调用是,大多执行系统会终止程序。然而,如果logTransaction是个正常的virtual(即impure)函数,并在Transaction内带有一份代码,该版本就会被调用,程序也会继续往下进行,只会造成创建一个derived class对象时会调用错误版本的logTransaction(而不报任何信息)。

唯一能避免这一问题的解决办法为:保证构造函数和析构函数都没有(在对象被创建和销毁期间)调用virtual函数,而他们调用的所有函数也都服从同一约束。

但是,又如何确保每一次都有Transaction继承体系上的对象被创建,就会有适当版本的logTransaction被调用?一种办法是在class Transaction内将logTransaction函数改为non-virtual,然后要求derived class构造函数传递必要的信息给Transaction构造函数,而后那个构造函数边可以安全的调用non-virtual logTransaction。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Transaction {
public:
explicit Transaction(const std::string& logInfo);
void logTransaction(const std:;string& logInfo) const;//此时,是一个non-virtual函数
...
};

Transaction::Transaction(const std::string& logInfo)
{
...
logTransaction(logInfo); //此时,是一个non-virtual调用
}

class BuyTransaction: public Transaction {
public:
BuyTransaction( parameters )
: Transaction(createLogString( parameters )) //将log信息传递给base class构造函数
{ ... }
...
private:
static std::string createLogString( parameters );
};

换句话说,由于无法使用virtual函数从base class向下调用,在构造期间,可以由“令derived classes将必要的构造信息向上传递至base class构造函数”替换并加以弥补。

记住:

在构造和析构期间不要调用virtual函数,因为这类调用从不下降至派生类(比起当前执行构造函数和析构函数的那层)。

条款10

令 operator= 返回一个reference to *this。

  • 赋值采用右结合律
  • 趣的一点,是你可以把它写出连锁赋值的形式。
1
2
3
int x, y, z;
x = y = z = 15;
//它相当于x=(y=(z=15));

为了实现这种连锁赋值,赋值操作符必须返回一个reference指向操作符的左侧实参。这也是实现class重载赋值操作符应该遵循的协议。这种协议不仅适用于标准赋值,也适用于所有赋值相关的运算(+=,-=,*=,/=,<<=,…)。

1
2
3
4
5
6
7
8
9
10
11
class Widget{
public:
Widget& operator=(const Widget& rhs){
//...
return *this;
}
Widget& operator+=(const Widget& rhs){
//...
return *this;
}
};

当然这只是协议,并无强制性,不遵循,代码一样可以通过编译。然而这份协议被所有内置类型和标准库提供的类型,如string,vector,complex,trl1::shared_ptr或即将提供的类型共同遵守,除非你有一个标新立异的好理由,不然还是遵守吧。

记住:

令赋值操作符返回一个reference to *this。

条款11

在 operator= 中处理“自我赋值”。

“自我赋值”发生在对象被赋值给自己时:

1
2
3
4
5
class Widget { ... };

Widget w;
...
w = w; //赋值给自己

虽然这种做法看起来比较傻,但是这种操作却是合法的,所以绝不要认定客户不会这么做。

此外,自我赋值并不是总是可以一眼分辨出来,例如:

1
a[i] = a[j];        //潜在的自我赋值

如果i和j具有相同的值时,这就是一个自我赋值。再比如:

1
*px = *py;          //潜在的自我赋值

如果指针px和py恰巧指向同一个东西,这也是一个自我赋值。

这些并不明显的复制行为,是“别名(aliasing)”所带来的结果。所谓“别名”:就是有一个以上的方法指称(指涉)某对象。

一般而言,如果某段代码操作pointers或references,而它们被从来“指向多个相同类型的对象”,就需要去考虑这些对象是否为同一个对象。实际上,两个对象只要来自同一个继承体系,它们甚至不需要声明为相同类型就可能会造成“别名”,因为一个base class的reference或者pointer可以指向一个derived class对象:

1
2
3
class Base { ... };
class Derived : public Base { ... };
void doSomethings(const Base& rb, Derived* pd); //rd和*pd有可能其实是同一个对象

在这里,假如说我们尝试自行管理资源(即打算写一个用于资源管理的class,就需要这样做),就可能会掉进“在停止使用资源之前意外释放了它”的陷阱。举个例子,假如建立一个class用来保存一个指针指向一块动态分配的位图(bitmap):

1
2
3
4
5
6
class Bitmap { ... };
class Widget {
...
private:
Bitmap* pb; //指针,指向一个从heap分配而得的对象
};

下面是operator= 的实现代码,看起来虽然合理,但是在进行自我赋值时并不安全:

1
2
3
4
5
6
Widget& Widget::operator=(const Widget& rhs)    //不安全的operator= 的实现版本
{
delete pb; //停止使用当前的bitmap
pb = new Bitmap(*rhs.pb); //使用rhs's bitmap的副本(复件)
return *this;
}

之所以会出现自我赋值的问题,是因为operator= 函数内的*this(赋值的目的端)和rhs有可能是同一个对象。如果它们是同一个对象,那么delete对象就不只是销毁当前对象的bitmap,它也同时销毁了rhs的bitmap。在函数末尾,Widget发现自己持有一个指针指向一个已被删除的对象。

想要阻止这样的错误,传统的做法是在operator= 最前面进行一个“证同测试(identity test)”,以此达到自我赋值的检验目的:

1
2
3
4
5
6
7
8
Widget& Widget::operator=(const Widget& rhs)
{
if(this == &rhs) //证同测试(identity test)
return *this; //如果是自我赋值,就不做任何事情
delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}

这种解决办法是行得通的。在第一个版本的operator= 中,不仅不具备“自我赋值安全性”,也不具备“异常安全性”,然而,这个新版本的operator=,仍然存在异常方面的问题:如果“new BItmap”导致了异常(比如因为分配时内存不足或者因为Bitmap的copy构造函数抛出异常),Widget最终会持有一个指针指向一个被删除的Bitmap。(如果具备异常安全,在new发生之前不delete原来的对象,以防new异常后不能恢复。)

这样的指针是有害的:既不能安全的删除它,也不能安全的读取它。唯一能对它们做的安全的事情就是付出很多调试的功夫,去找到错误的起源。


当我们让operator= 具备“异常安全性”时,往往会自动获得“自我赋值安全性”的特性。因此,很多时候,并不专门去解决“自我赋值”的问题,而是将注意力放在“异常安全性(exception safety)”之上。例如对于下面,只需要注意在赋值pb所指的东西之前不要删除pb即可:

1
2
3
4
5
6
7
Widget& Widget::operator=(const Widget& rhs)
{
Bitmap* pOrig = pb; //记住原先的pb
pb = new Bitmap(*rhs.pb); //令pb指向*pb的一个副本(复件)
delete pOrig; //删除原先的pb
return *this;
}

于是,如果“new Bitmap”抛出异常,pb(及其栖身的那个Widget)也会保持原状。即使没有证同测试,这段代码也是能够处理自我赋值问题,因为我们对原bitmap做了一份复件、删除原bitmap、然后指向新制造的那个复件。

当然,如果我们想要提高效率,也可以将证同测试重新放到函数的起始处。然而这样做之前先问问自己,你估计“自我赋值”的发生频率有多高?因为这项测试也需要成本。它会使代码变大一些(包括原始码和目标码)并导入一个新的控制流分支,而两者都会降低执行速度。


一个替代方案是,使用copy and swap技术。我在惯用法中更详细地说明了这项技术,事实上,内容基本包含了这一章节的内容。下面是简单的介绍。

1
2
3
4
5
6
7
8
9
10
11
12
class Widget {
...
void swap(Widget& rhs); //交换*this和rhs的数据
...
};

Widget& Widget::operator=(const Widget& rhs)
{
Widget temp(rhs); //为rhs数据制作一份复件
swap(temp); //将*this数据和上述的复件进行数据交换
return *this;
}

另一种更为高效简洁的写法为:

1
2
3
4
5
6
//这个写法更快的原因是参数通过值传递,抵消了创建temp的过程。
Widget& Widget::operator=(Widget rhs) //rhs直接就是被传对象的一份复件,此时是pass by value
{
swap(rhs); //将*this数据和上述的复件进行数据交换
return *this;
}

这种方法之所以可以,是因为:

  • 某class的copy assignment操作符可能被声明为“以by value方法接受实参”
  • 以by value方法传递东西会形成一份复件。

这种方法牺牲了代码的清晰性,但是却将“copy动作”从函数本体内移至“函数参数构造阶段”,使得编译器生成了更有效的代码。

记住:

  • 确保当对象自我赋值时 operator= 有良好行为。其中技术包括比较“来源对象”和“”目标对象“的地址、精心周到的语句顺序、以及copy-and-swap。
  • 确定任何函数如果操作一个以上的对象,而其中多个对象是同一个对象时,其行为仍然正确。

条款12

复制对象时勿忘其每一个部分。

设计良好的面向对象的系统,会将对象的内部封装起来,只留下两个函数来负责对象的拷贝(复制):

  • copy构造函数
  • copy assignment操作符

我们将它们一起成为copying函数。

假如我们要声明自己的copying函数,即告诉编译器自己不会去使用缺省实现的某些行为,那么此时编译器会在代码几乎必然出错的情况下,却不去告诉你。

举个例子,考虑一个class,用来表示顾客,其中人为地书写copying函数(而非由编译器去创建),使得外界对它们的调用都会记录下来(logged):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void logCall(const std::string& funcName);  //制作一个log entry

class Customer {
public:
...
Customer(const Customer& rhs); //copy构造函数
Customer& operator=(const Customer& rhs); //copy assignment操作符
...
private:
std::string name;
};

Customer::Customer(const Customer& rhs) : name(rhs.name) //复制rhs的数据
{
logCall("Customer copy constructor");
}

Customer& Customer::operator=(const Customer& rhs)
{
logCall("Customer copy assignment operator);
name = rhs.name; //复制rhs的数据
return *this;
}

虽然上面的代码看起来并没有什么问题,但是当另一个变量加入到其中时:

1
2
3
4
5
6
7
8
class Date { ... };     //日期
class Customer {
public: //定义与前面相同
...
private:
std::string name;
Date lastTransaction;
};

此时,既有的copying函数执行的是局部拷贝(partial copy):它们只复制了顾客的name,而没有复制新添加的lastTransaction。

这明显是个错误,但是编译器却并不会报错(即使在最高级别的警告中)。因此,如果我们为class添加一个新的成员变量时,就必须同时修改copying函数。(同时也需要修改class的所有构造函数以及任何非标准形式的operator=)。


另外,一旦发生继承,则会造成一个更严重的危机:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class PriorityCustomer: public Customer {   //定义Derived class
public:
...
PriorityCustomer(const PriorityCustomer& rhs); //
PriorityCustomer& operator=(const PriorityCustomer& rhs);
...
private:
int priority;
};

PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
: priority(rhs.priority) //复制rhs的数据
{
logCall("PriorityCustomer copy constructor");
}

PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
logCall("PriorityCustomer copy assignment operator");
priority = rhs.priority;
return *this;
}

上面的代码中,PriorityCustomer的copying函数看起来好像是复制了PriorityCustomer内的每一样东西,但是实际上,它们只是复制了PriorityCustomer声明的成员变量,但是每个PriorityCustomer还内含了它所继承的Customer成员变量复件,而那些成员变量并没有被复制。即:Derived class没有连同base class中的字段一起复制。

PriorityCustomer的copy构造函数并没有指定实参传给其base class构造函数。(也即说它在它的成员初值列(member initialization list)中没有提到Customer)。

因此,PriorityCustomer对象的Customer成分会被不带有Customer构造函数(即default构造函数)初始化。default构造函数将会针对name和lastTransaction执行缺省的初始化动作。对于PriorityCustomer,它不曾企图修改其base class的成员变量,因此那些成员变量保持不变。


任何时候只要你为派生类实现copying函数,必须很小心地也复制基类成分,那些成分往往是private,所以无法直接访问,应该让派生类的copying函数调用相应的基类函数:

1
2
3
4
5
6
7
PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
logCall("PriorityCustomer copy assignment operator");
Customer::operator=(rhs);//对base class成分进行赋值动作
priority = rhs.priority;
return *this;
}

当你编写一个copying函数时,请确保:

  • 复制所有local成员变量;
  • 调用所有基类内的适当的copying函数。

copying函数往往有近似相同的实现,但需要记住的是:令某个copying函数调用另一个copying函数无法达到目标,应把相同的实现放入第三个函数中由两个copying函数调用(通常命名为init,且为private)。

原因是,拷贝构造函数是针对未初始化的对象的操作,而赋值操作符只能施行于已初始化对象身上。当对象已初始化时,使用赋值操作符调用”只能作用于未初始化对象“的拷贝构造函数是没有意义的;同样,对象未初始化时,使用拷贝构造函数调用”只能施行于已初始化对象“的赋值操作符也是没有意义的;乃至于根本没有相关语法。

记住:

  • copying函数应确保复制”对象内的所有成员变量“及”所有base class 成分“;
  • 不要尝试以某个copying函数实现另一个copying函数。应将共同技能放进第三个函数中,并由两个copying函数共同调用。

条款13

以对象管理资源

假设我们使用一个用来塑模投资行为(例如股票、债券等)的程序库,各种各样的投资类型继承自root class Investment。进一步假设这个库使用了通过一个 factory 函数为我们提供特定 Investment 对象的方法:

1
2
3
4
5
6
7
8
9
10
11
12
class Investment { ... }; // “投资类型”继承体系中的root class

Investment* createInvestment(); /*返回指向Investment继承体系内的动态分配对象的指针。调用者有责任删除它。这里为了简化,刻意不写参数*/

//当 createInvestment 函数返回的对象不再使用时,由调用者负责删除它。下面的函数 f 来履行以下职责:

void f()
{
Investment *pInv = createInvestment(); // 调用factory对象
...
delete pInv; // 释放pInv所指对象
}

以下几种情形会造成 f 可能无法删除它得自 createInvestment 的投资对象:

  • “…” 部分的某处有一个提前出现的 return 语句,控制流就无法到达 delete 语句;
  • 对 createInvestment 的使用和删除在一个循环里,而这个循环以一个 continue 或 goto 语句提前退出;
  • “…” 中的一些语句可能抛出一个异常,控制流不会再到达那个 delete。

单纯依赖“f总是会执行其delete语句”是行不通的,因为代码可能会在时间渐渐过去后被其他人修改、维护。

为了确保 createInvestment 返回的资源总能被释放,我们需要将资源放入对象中,当控制流离开f,这个对象的析构函数会自动释放那些资源。将资源放到对象内部,我们可以依赖 C++ 的“析构函数自动调用机制”确保资源被释放。

许多资源都是动态分配到堆上的,并在单一区块或函数内使用,且应该在控制流离开那个块或函数的时候释放。标准库的 auto_ptr 正是为这种情形而设计的。auto_ptr 是一个类似指针的对象(智能指针),它的析构函数自动对其所指对象调用 delete。下面就是如何使用 auto_ptr 来预防 f 的潜在的资源泄漏:

1
2
3
4
5
void f()
{
std::auto_ptr<Investment> pInv(createInvestment()); // 调用工厂函数
... // 一如以往地使用pInv
} // 经由auto_ptr的析构函数自动删除pInv

这个简单的例子示范了“以对象管理资源”的两个关键想法:

  • 获得资源后应该立即放进管理对象内。
  • 管理对象使用它们的析构函数确保资源被释放。

auto_ptr 和 tr1::shared_ptr 都在它们的析构函数中使用 delete,而不是 delete []。这就意味着将 auto_ptr 或 tr1::shared_ptr 用于动态分配的数组是个馊主意。C++ 中没有可用于动态分配数组的类似 auto_ptr 或 tr1::shared_ptr 这样的东西,甚至在 TR1 中也没有。那是因为 vector 和 string 几乎总是能代替动态分配数组。

如果你手动释放资源(例如,使用 delete,而不使用资源管理类),你就是在自找麻烦。像 auto_ptr 和 tr1::shared_ptr 这样的预制的资源管理类通常会使本条款的建议变得容易,但有时你所使用的资源是目前这些预制的类无法妥善管理的,你就需要精心打造自己的资源管理类。

记住:

  • 为防止资源泄露,请使用RAII对象,它们在构造函数中获得资源并在析构函数中释放资源。
  • 两个常被使用的RAII classes 分别是tr1::shared_ptr和auto_ptr。前者通常是较佳选择 ,因为其copy行为比较直观。若选择auto_ptr,复制动作会使被复制物指向null。

条款14

在资源管理类中小心copying行为。

如果一个RAII对象被复制,会发生什么?

在上一个条款之中,提到了“资源获得的时机就是初始化时机”(Resource Acquisition Is Initialization(RAII)),并且说明了auto_ptr和tr1::shared_ptr如何在heap-based的资源上作用的。但是,并不是所有的资源都是“heap-based”的,对于这种资源,上述的这两种指类指针对象就不再适合作为资源管理者了。于是,我们需要建立自己的资源管理类,

举个例子,加入使用C API函数处理类型为Mutex的互斥器对象(mutex objects),共有lock和unlock两个函数可以使用:

1
2
3
4
5
6
7
8
class Lock {
public:
explict Lock(Mutex* pm) : mutexPtr(pm)
{ lock(mutexPtr); } //获得资源
~Lock() ( unlock(mutexPtr); ) //释放资源
private:
Mutex *mutexPtr;
};

在使用Lock时,符合RAII的标准:

1
2
3
4
5
6
Mutex m;        //定义所需要的互斥器
...
{ //建立一个区块用来定义critical section
Lock ml(&m); //锁定互斥器
... //执行critical section内的操作
} //在区块结尾处,会自动解除互斥器的锁定

这样的操作的是没有问题,但是如果此时Lock对象被复制:

1
2
Lock ml1(&m);       //锁定m
Lock ml2(m1l); //将ml1复制到ml2之上

这个时候,对于这种情况,一般有两种选择:

  • 禁止复制。因为许多时候,允许RAII被复制并不合理。如果复制动作对于RAII对象不够合理,就应该禁止。实现这种禁止,即用到了条款6中所说的办法:将copying操作声明为private。
  • 对底层资源使用“引用计数法”。有的时候,我们希望保持着资源,直到它的最后一个使用者(某个对象)被销毁。在这种情况下,复制RAII对象时,应该将资源的“被引用数”进行递增。tr1::shared_ptr就是如此。

通常只要内含一个tr1::shared_ptr成员变量,RAII classes就可以实现reference-counting copying行为(使用默认的拷贝构造函数即可,因为这时会对成员变量调用其拷贝构造)。假如前面的Lock想要使用reference counting,它可以直接去改变mutexPtr的类型即可:将其从Mutex*改为tr1::shared_ptr< Mutex >。

但是,需要注意的是,tr1::shared_ptr的默认行为为:当引用次数为0时,删除其所指物。删除操作并不是我们想要的,当我们使用一个Mutex,我们要做的释放动作是解除锁定而并非删除。

因此,tr1::shared_ptr是允许定义所谓的“删除器(deleter)”的,这是一个函数或者函数对象(function object),当引用的次数为0时被调用(这个功能并不存在与auto_ptr中,它总是将其指针删除)。这个删除器对于tr1::shared_ptr是第二个参数:

1
2
3
4
5
6
7
8
9
10
11
class Lock {
public:
explict Lock(Mutex* pm) //以某个Mutex初始化shared_ptr
: mutexPtr(pm, unlock) //并且unlock函数作为删除器
{
lock(mutexPtr.get());
}
//注意!没有析构函数!
private:
std::tr1::shared_ptr<Mutex> mutexPtr; //使用shared_ptr代替raw pointer
};

在条款5中说过,无论是编译器自动生成的、还是用户自定义的,class 析构函数都会自动调用其non-static成员变量(在这里是mutexPtr)的析构函数,使计数器减1。而mutexPtr的析构函数会在互斥器的引用计数为0时自动调用tr1::shared_ptr的删除器(在这里是unlock)。也就是说,之所以没有声明析构函数,完全只是依赖了编译器生成的缺省行为。

总之,对RAII对象的复制基本就两种情况:

  • 复制底部资源。可以是多个对象指向一份资源(引用计数);也可以深度拷贝,对这个资源多生成一份副本(从内存来看是不同的)。
  • 转移底部资源的拥有权。确保永远只有一个RAII对象指向一个原始资源。

记住:

  • 复制RAII对象必须一并复制它所管理的资源,所以资源的copying行为决定RAII对象的copying行为。
  • 普遍而常见的RAII class copying 行为是:抑制copying、施行引用计数法。不过其他行为也都可能被实现。

条款15

在资源管理类中提供对原始资源的访问。

所谓资源管理类(resource-managing classes),可以有效的帮助我们去预防资源泄漏。但是,虽然在理想的情况下,我们希望所有的资源与对象之间的互动都依赖于这样的资源管理类,仍有许多的API会直接去涉及原始资源(raw resource)。

举个例子,在前面的条款13中,我们可以得知:使用智能指针auto_ptr或者tr1::shared_ptr来保存factory函数例如createInvestment的调用结果:

1
std::tr1::shared_ptr<Investment> pInv(createInvestment());//pInv本身是shared_ptr,内部的成员指针才是Investment的

如果我们希望用某个函数来处理Investment对象:

1
int dayHeld(const Investment* pi);      //返回投资的天数

此时,如果我们想要这样去调用:

1
int days = daysHeld(pInv);      //错误!!

这样做是错误的,因为daysHeld需要的是Investment的指针,而并非此时传递给它的类型为tr1::shared_ptr的对象。

因此,此时需要一个函数,可以将RAII class对象转换为其所内含的原始资源(即tr1::shared_ptr -> Investment*)
有两种方法可以实现这样的功能:

  • 显式转换:tr1::shared_ptr和auto_ptr都提供了一个get成员函数,用来执行显式转换。也就是说,可以返回智能指针内部的原始资源(的复件):

    1
    int days = daysHeld(pInv.get());    //成功的将pInv内的原始指针传给了daysHeld
  • 隐式转换:就像几乎所有的智能指针一样,这些对类指针对象都重载了指针取值(pointer dereferencing)操作符(operator ->和operator *),它们允许隐式转换至底部的原始指针:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    class Investment {    //investment继承体系的根类
    public:
    bool isTaxFree () const;
    ...
    };

    Investment* createInvestment(); //factory函数

    std::tr1::shared_ptr<Investment> pi1(createInvestment()); //令tr1::shared_ptr管理一笔资源
    bool taxable1 = !(pi1->isTaxFree()); //经由operator->访问资源
    ...

    std::auto_ptr<Investment> pi2(createInvestment()); //令auto_ptr管理一笔资源
    bool taxable2 = !((*pi2).isTaxFree()); //经由operator*访问资源
    ...

    因为有时需要必须取得RAII内部的原始资源,一般的做法是提供一个隐式转换函数(operator A(),A的目标类型)。举个例子,对于用于字体的RAII class(对于C API而言,字体是一种原始数据结构、即原始资源)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    FontHandle getFont();       //这是一个C API

    void releaseFont(FontHandle fh); //来自同一组的C API
    class Font { //RAII class
    public:
    explicit Font(FontHandle fh) : f(fh) //获得资源,采用pass-by-value的方法
    { }
    ~Font() { releaseFont(f); } //释放资源
    private:
    FontHandle f; //原始(raw)字体资源
    };

    假设有大量与字体相关的C API,它们处理的都是FontHandle,那么“将Font对象转换为FontHandle”将会是一件非常繁琐的事情。因此,Font class可以提供一个显式的转换函数,就像上面的get一样:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    class Font {
    public:
    ...
    FontHandle get() const { return f; } //显式转换函数
    ...
    };
    //然而,每次用户想要使用API时,都必须要调用get:
    void changeFontSize(FontHandle f, int newFontSize); //C API
    Font f(getFont());
    int newFontSize;
    ...
    changeFontSize(f.get(), newFontSize); //明确地将Font转换为FontHandle

    另外一种办法则是令Font提供隐式转换函数,转换的类型FontHandle:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    class Font {
    public:
    ...
    operator FontHandle() const //隐式转换函数
    { return f; }
    ...
    };
    //这样,在调用C API时,就非常的方便了:
    Font f(getFont());
    int newFontSize;
    ...
    changeFontSize(f, newFontSize); //将Font隐式地转换为FontHandle

    但是,这种隐式转换却会增加错误的发生机会。例如,当我们需要使用Font时,却会意外的创建一个FontHandle:

    1
    2
    3
    Font f1(getFont());
    ...
    FontHandle f2 = f1; //注意!这里的本意是拷贝一个Font对象,但是因为隐式转换的原因(f2前面的FontHandle),将f1给隐式转换为了FontHandle,然后才执行了复制操作

    在上面的程序中,FontHandle由Font对象f1进行管理,但是这个FontHandle也可以直接通过f2进行取得。这样就会引发问题,例如当f1被销毁时,字体会被释放,而f2因此会成为“虚吊的”(dangle)。


综上所说,是否应该提供一个显式转换函数(例如get成员函数)将RAII class转换为其底部资源,还是提供隐式转换,取决于其执行的具体功能,具体说来:

  • 让接口容易被正确使用,不易被误用。因此,通常显式转换函数如get就是比较好的方法,因为显式转换将非故意的类型转换的发生的可能性最小化了。
  • 然而有时候隐式类型转换所带来的自然用法也能取得方便。

最后,值得说明的是,从RAII class 获取原始资源并没有什么矛盾(你可能认为这样的类封装是失败的)。关于RAII class,它们并不是为了封装,而是为了确保:资源释放,这一行为,一定会发生。

记住:

  • APIs往往要求访问原始资源,所以每一个RAII class 应该提供一个“取得其所管理之资源”的办法。
  • 对原始资源的访问可能经由显式转换(比如一个get成员函数)或隐式转换(重载operator A())。一般而言显式转换比较安全,但隐式转换对客户比较方便。

条款16

成对使用new和delete时要采取相同形式。

这章内容比较简单,就是“单一对象”和“对象数组”的问题。

  • 当你使用delete[]时,实际上是告诉编译器我要delete的对象是一个数组,让它得知内存中存在一个“数组大小记录”,这样才能释放完全空间。
  • 如果对象不是数组但依然用了delete[],就会导致编译器在解释内存空间时误认为某一项是“数组大小”,然后错误地释放空间。
  • 最后,不要对数组形式做typedef动作,因为这会导致你new时不容易发现到底有没有[],从而导致delete出错。

记住:

如果你在new表达式中使用[],必须在相应的delete表达式中也使用[]。如果你在new表达式中不使用[],一定不要在相应的delete表达式中使用[]。

条款17

以独立语句将newed对象置入智能指针。

首先,假设我们有个函数用来显示处理函数的优先权,另一个函数用来在某动态分配所得的Widget上进行某些带有优先权的处理:

1
2
int priority();
void processWidget(std::tr1::shared_ptr<Widget> pw, int priority);

根据“以对象管理资源”的条款,processWidget使用智能指针来对动态分配得到的Widget进行管理(即tr1::shared_ptr)

当我们试图调用processWidget:

1
processWidget(new Widget, priority());//这种情况会调用拷贝构造函数

需要注意的是,此时是不能通过编译的。tr1::shared_ptr构造函数需要一个原始指针(raw pointer),但是这个构造函数是一个explicit构造函数,无法进行隐式转换。因此,需要将“newWidget”的原始指针转换为processWidget所需要的tr1::shared_ptr,因此需要这样写:

1
processWidget(std::tr1::shared_ptr<Widget> (new Widget), priority ());//先使用构造函数,再拷贝构造

但是,虽然此时我们使用了“对象管理式资源”,仍然会产生资源的泄漏!

在编译器产生一个processWidget调用代码之前,首先需要确定把即将被传递的各个实参是什么。在上面的代码中,第二个实参较为简单,只是一个对priority函数的调用;而第一个实参则较为复杂,对于这个实参std::tr1::shared_ptr< Widget > (new Widget),它有两部分组成:

  • 执行“new Widget”表达式
  • 调用tr1::shared_ptr构造函数

因此,在调用processWidget之前,编译器需要创建代码,做以下三件事情:

  • 调用priority
  • 执行“new Widget”
  • 调用tr1::shared_ptr构造函数

对于这三件事情,对于C++而言,“new Widget”的执行次序一定是在“tr1::shared_ptr构造函数被调用”之前,因为这个表达式的结果还需要被传递作为tr1::shared_ptr构造函数的一个实参。但是对于priority的调用而言,则是可以在第一、第二或者第三执行。假如选择在第二位执行,可以得到这样的执行序列:

  • 1.执行“new Widget”
  • 2.调用priority
  • 3.调用tr1::shared_ptr构造函数

因此,对于上面这个执行次序,如果priority的调用产生异常,此时“new Widget”返回的指针将会遗失,因为它尚未被置入tr1::shared_ptr内,而这个智能指针的存在就是为了防止资源泄漏的。因此,在对processWidget的调用过程中是会引发资源泄漏,因为在“资源被创建(经由“new Widget”)”和“资源被替换为资源管理对象”这两个时间点之间可能会被发生干扰。

为了避免这样的问题,解决办法为分离语句。即分别写出:

  • 1.创建WIdget

  • 2.将它置入一个智能指针之中,然后再将这个智能指针传给processWidget:

    1
    2
    3
    std::tr1::shared_ptr<Widget> pw(new Widget);    //在单独的语句内以智能指针存储newed所得对象

    process(pw priority()); //这个调用动作就不会造成泄漏

之所以可以解决这个问题,是因为编译器对于“跨越语句的各项操作”没有重新排列的自由(只有在语句内它才拥有这个自由)。在上述修改后的代码中,“new Widget”表达式以及“对tr1::shared_ptr构造函数的调用”这两个动作,与“对priority的调用”是分隔开的,在不同的语句之中,因此编译器就不能再它们之间任意选择执行次序。

记住:

以独立语句将newed对象存储于(置入)智能指针内。如果不这样做,一旦异常被抛出(在资源对象产生到置入资源管理对象之间,产生异常的话),有可能导致难以察觉的资源泄漏。

条款18

让接口容易被正确使用,不易被误用。

想要开发出一个“容易被正确使用,不易被误用”的接口,首先就需要明白使用这样的接口可能会产生怎样的错误?举个例子,假设我们为一个用来表示日期的class设计构造函数:

1
2
3
4
5
class Date {
public:
Date(int month, int day, int year);
...
};

虽然这样定义的构造函数乍一看没什么问题,但其实在调用时,却可能会产生以下两个错误:

1
2
3
4
5
//错误的传递参数次序:
Date d(30, 3, 1995); //把月和日传递反了

//传递一个无效的月份或天数:
Date d(2, 30, 1995); //二月有30号??

想要解决这样的问题,类型系统(type system)是一个关键办法。我们使用外覆类型(wrapper type)来区别这样的天数、月份和年份,然后在Date构造函数中使用这些类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct Day {
explicit Day(int d) : val(d) { }
int val;
};

struct Month {
explicit Month(int m) : val(m) { }
int val;
};

struct Year {
explicit Year(int y) : val(y) { }
int val;
};

class Date {
public:
Date(const Month& m, const Day& d, const Year& y);
...
};

Date d(30, 3, 1995); //错误的类型!
Date d(Day(30), Month(3), Year(1995)); //错误的类型!
Date d(Month(3), Day(30), Year(1995)); //正确了!

当我们确定好类型的定义,限制它们的取值也是非常重要。比如,月份的取值只能是1-12。方法之一,就是利用enum表现月份。不过enum并不具备类型安全性。因此,比较安全的解法是预先定义所有有效的Months:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Month {
public:
static Month Jan() { return Month(1): } //函数,返回有效的月份(本质是构造函数)
static Month Feb() { return Month(2); } //“以函数替换对象”,请回忆条款4中non-local static对象问题
...
static Month Dec() { return Month(12);}
... //其他的成员函数
private:
explicit Month(int m) //阻止生成新的月份
...
};

Date d(Month::Mar(), Day(30), Year(1995));
  • 预防错误的另一种方法,限制类型内什么事情可以做,什么不可以做。常见的限制是加上const。在前面的条款3中,就对const的作用进行了说明。
  • “让types容易被正确使用,不容易被误用”,进一步表现为:除非有更好的理由,否则应该尽量让我们定义的types的行为与内置的types一致。一旦有怀疑,就拿int类型当范本。比如a*b这个表达式不允许赋值。
  • 避免无端的与内置类型不兼容,真正的理由是为了提供行为一致的接口。STL容器的接口就比较一致,这就使得它们非常容易被使用。例如,每个STL容器都有size函的成员函数,它会告诉调用者目前容器内的对象的个数。

如果一个接口要求使用者必须记住某些事情,就会有着“不正确使用”的倾向,因为使用者很有可能忘记做这件事。举个例子,在条款13中所说的factory函数,它会返回一个指针指向Investment继承体系内的一个动态分配的对象:

1
Investment* createInvestment();

为了避免资源的泄漏,createInvestment返回的指针最终必须被删除,但是在这个过程中,至少可能会出现两个错误机会:(1)没有删除指针。(2)删除同一个指针超过一次。

在条款13中提供的解决办法是将createInvestment的返回值存储在一个智能指针内,于是delete的责任就赋予了智能指针。不过,使用者如果忘记使用智能指针,则会出现问题。因此,较为理想的接口就是令factory函数返回一个智能指针:

1
2
3
//这实际上就是强迫使用者将返回值存储在一个tr1::shared_ptr内,
//因此几乎就消除了忘记删除底部Investment对象的可能性。
std::tr1::shared_ptr<Investment> createInvestment();

假设class的设计,希望“从createInvestment取得Investment*指针”,将该指针传递给一个名为getRidOfInvestment的函数,而不是直接delete。那么这样设计的一个接口(上面那个)却会产生新的错误:企图使用错误的资源析构机制——即使用delete替换getRidOfInvestment。

对于这个问题,一个解决办法则是:返回一个“将getRidOfInvestment绑定为删除器(deleter)”的tr1::shared_ptr。

在前面的条款中有讲到过,tr1::shared_ptr提供的构造函数有两个实参:

  • (1)被管理的指针
  • (2)引用次数变成0时被调用的“删除器”

由此可得,我们可以创建一个null tr1::shared_ptr并以getRidOfInvestment作为其删除器:

1
2
3
//试图创建一个null shared_ptr并携带一个自定的编译器
std::tr1::shared_ptr<Investment> pInv(0, getRidOfInvestment);
//错误的形式!无法通过编译!!

上面的代码是不能通过编译的,因为tr1::shared_ptr构造函数的第一个参数必须是个指针,而0不是指针。因此,转型(cast)可以解决这样的问题:

1
2
//正确地创建一个null shared_ptr并携带一个自定的编译器
std::tr1::shared_ptr<Investment> pInv(static_cast<Investment*>(0), getRidOfInvestment);

因此,如果我们想要实现createInvestment使它返回一个tr1::shared_ptr并夹带getRidOfInvestment函数作为删除器,代码如下:

1
2
3
4
5
6
std::tr1::shared_ptr<Investment> createInvestment()
{
std::tr1::shared_ptr<Investment> retVal(static_cast<Investment*>(0), getRidOfInvestment);
retVal = ...; //令retVal指向正确的对象
return retVal;
}

当然,如果被管理的原始指针可以在建立智能指针之前先确定下来,那么将原始指针传给构造函数会比先初始化为null再赋值更好。


tr1::shared_ptr的一个优秀的性质是:它会自动的使用它的“每个指针专属的删除器”这样的性质消除了一个潜在的可能错误:“cross-DLL problem”。

这个问题发生于“对象在动态链接库(DLL)中被创建,但却在另一个DLL内被delete销毁”。在一些平台上,这一类“跨DLL的new/delete成对运用”会导致运行期间错误。

然而tr1::shared_ptr就没有这个问题,因为它默认的删除器是来自“tr1::shared_ptr诞生所在的那个那个DLL”的delete。举个例子来说,如果Stock派生自Investment,而createInvestment的实现如下:

1
2
3
4
std::tr1::shared_ptr<Investment> createInvestment()
{
return std::tr1::shared_ptr<Investment>(new Stock);
}

返回的那个tr1::shared_ptr可被传递给任何其他的DLLs,无需在意“cross-DLL problem”。这个指向Stock的tr1::shared_ptr会追踪记录“当Stock的引用次数变成0时该调用的那个DLL’s delete”。

记住:

  • 好的接口很容易被正确使用,不容易被误用。你应该在你的所有接口中努力达成这些性质。
  • “促使正确使用”的办法包括接口的一致性,以及与内置类型的行为兼容。
  • “阻止误用”的办法包括建立新类型、限制类型上的操作,束缚对象值,以及消除客户的资源管理责任。
  • tr1::shared_ptr支持定制型删除器。这可防范DLL问题,可被用来自动解除互斥锁(见条款14)等等。

条款19

设计class犹如设计type。

当我们定义一个新的class时,就相当于定义了一个新的type。

如何设计一个高效的classes?主要面对以下的问题:

  • 新type的对象应该如何被创建和销毁?这会影响到class的构造函数和构造函数以及内存分配函数和释放函数(operator new, operator new[], operator deleter和operator deleter[])。
  • 对象的初始化和对象的赋值应该有什么差别?这个问题的答案,决定了构造函数和赋值(assignment)操作符的行为,以及它们之间的差异。需要注意的是,“初始化”和“赋值”是不同的,因为他们应用于不同的函数调用。(详见条款4)
  • 新type的对象如果被passed by value(以值传递),会怎样?需要记住的是,copy构造函数用来定义一个type的pass-by-value应该如何去实现。
  • 什么是type的“合法值”?对class的成员变量来说,通常某些只有数值集是有效的。那些数值集决定了class需要维护的约束条件(invariants),因此,也就决定了成员函数(特别是构造函数、赋值操作符以及所谓的“setter”函数)必须要进行错误检查工作。
  • 创建的新type需要配合某一个继承图系(inheritance graph)么?如果继承来自某些既有的classes,那么设计的新classes就受到了束缚,特别是受到“它们的函数是virtual或者non-virtual”的影响。如果我们定义的class允许其他class去继承,这样会影响我们所声明的函数——尤其是析构函数——是否为virtual(详见条款7)。
  • 创建的新type需要怎样的转换?如果我们希望允许类型T1可以被隐式地转换为类型T2,就必须在class T1中写一个类型转换函数(operator T2)或者在class T2内写一个non-explicit-one-argument(可被单一实参调用)的构造函数。如果我们只允许explicit构造函数存在,就必须写出专门负责执行转换操作的函数。
  • 什么样的操作符和函数对新创建的type而言是合理的?针对这个问题,需要决定class声明哪些函数,在这些函数中,哪些应该是member 函数,哪些则不是。
  • 什么样的标准函数应该被驳回?这些函数是必须声明为private的函数(详见条款7)。
  • 谁来取用新的type成员?这个问题可以帮助我们决定哪些成员应该是public、protected和private;同时也可以帮我们决定哪一个class/function应该是friends,以及将它们嵌套到另一个之内是否合理。
  • 什么是新type的“未声明接口”(undeclared interface)?它会对效率、异常安全性以及资源运用(例如多任务锁定和动态内存)提供何种保证?你在这些方面提供的保证将为你的class实现代码加上相应的约束条件。
  • 创建的新的type有多么一般化?如果说我们所需要的不是一个type,而是需要一整个types家族,那么我们就需要定义一个新的class template。
  • 真的需要定义一个新的type吗?如果只是定义新的derived class以便既有的class添加功能,那么单纯的定义一个或多个non-member函数或者template,或许更能达到目标。

记住:

class的设计就是type的设计。在定义一个新type之前,请确定你已经考虑过本条款覆盖的所有讨论主题。

条款20

宁以pass-by-reference-to-const替换pass-by-value。

在一般的情况下,默认情况中C++会以by value的方式传递对象自(或来自)函数。除非我们去特别指定,否则函数参数都是以实际实参的复件为初值,而调用端获得的也是函数返回值的一个复件。这些复件是由对象的copy构造函数生成的,这可能会造成pass-by-value称为较为费事儿的操作。

对于下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Person {
public:
Person();
virtual ~Person();
...
private:
std::string name;
std::string address;
};
class Student:public Person {
public:
Student();
!Student();
...
private:
std::string schoolName;
std::string schoolAddress;
};

这时,我们调用函数validateStudent,该函数需要一个Student实参(by value)并返回它是否是有效的:

1
2
3
bool validateStudent(Student s);   //函数以by value的形式接受学生
Student white; //定义一个学生white
bool whiteIsOk = validateStudent(white); //调用函数

当我们执行上面的代码时:首先,Student的copy构造函数会被调用,以white为蓝本将s进行初始化。同时,当validateStudent返回s时会被销毁。因此,对于此函数而言,参数的传递成本是:

  • 一次Student copy构造函数调用
  • 一次Student析构函数调用

但是!这还不算完!Student对象内有两个string对象,因此每次构造一个Student对象也就构造了两个string对象。此外,Student对象继承自Person对象,因此,每次每次构造Student对象是也必须构造一个Person对象。一个Person对象又有两个string对象,因此,每一次Person的构造动作也要承担两个string对构造动作。

于是,最终结果是:以by value的形式传递一个Student对象会调用一次Student对象会导致调用一次Student copy构造函数、一次Person copy构造函数、四次string copy构造函数。因此,当函数内的Student复件被销毁时,每一个构造函数调用动作都会对应一个析构函数调用动作。以by-value方式传递一个Student对象,

总体成本是:六次构造函数 和 六次析构函数。


这是一个非常大的代价了。想要回避这样的大代价的一个办法就是pass by reference-to-const:

1
bool validateStudent(const Student& s);

这样的传递方法,效率要高的多:没有构造函数或者析构函数被调用,因为没有任何新对象被创建。

在上面修改后的代码,参数const是非常重要的。因为在原先的validateStudent中参数是以by-value的形式进行传递的,因此变相的就告诉我们这个传递的参数是收到保护的,函数内绝不会对传入的Student进行任何的更改,能更改也只是对Student的复件进行修改。

于是,Student以by reference方式的传递,将它声明为const是必要的,使得确保传递的Student不至于被修改。


以by reference方式传递参数,也可以避免slicing(对象切割)问题。

当一个derived class对象以by value方式进行传递,并被视为一个base class对象,base class的copy构造函数会被调用,然而:“造成此对象的行为像一个derived class对象”的那些特质化的特征全部被切割掉了,仅仅留下了一个base class对象。造成这种情况的原因是,这个对象正是base class构造函数进行建立的,然而这种现象是我们一定不希望看到的。

举个例子,假设我们定义一组class,用来实现一个图形窗口系统:

1
2
3
4
5
6
7
8
9
10
11
12
class Window {
public:
...
std::string name() const; //返回窗口的名称
virtual void display() const; //显示窗口和其中的内容
};

class WindowWithScrollBars : public Window {
public:
...
virtual void display() const;
};

对于所有的Window对象,都有一个名称,我们可以通过name函数获取。所有的窗口显示,我们也可以通过display函数来进行实现。
其中,display函数是一个virtual函数,这就意味着base class Window对象的显示方式和WindowWithScrollBars对象的显示方式是不同的。

而当我们希望写一个函数去打印窗口的名称,然后显示该窗口,下面的写法是错误的:

1
2
3
4
5
void printNameAndDisply(Window w)   //不正确,参数可能会被切割
{
std::cout<<w.name();
w.display();
}

当我们调用上述的参数并向其传递一个WindowWithScrollBars对象时:

1
2
WindowWithScrollBars wwsb;
printNameAndDisplay(wwsb);

此时,参数w会被构造称为一个Window对象,因为它是pass by value的。于是,使得wwsb“之所以是一个WindowWithScrollBars对象”的所有特征都会被切割掉,简而言之:在printNameAndDisplay函数内不管是传递过来的对象时什么类型,参数w就像是一个Window对象。
因此,在printNameAndDisplay函数内调用display调用的总是Window::display,而绝不会是WindowWithScrollBars::display。

解决切割(slicing)问题的办法,就是以by reference-to-const方式传递w:

1
2
3
4
5
void printNameAndDisplay(const Window& w)
{
std::cout<<w.name();
w.display();
}

此时,传进来的窗口的是什么类型,w就表现出那种类型。


当我们观察C++编译器底层时,可以看到reference往往是以指针来实现的,因此:pass by reference通常意味传递的是指针。

  • 根据这点,如果我们需要传递的对象属于内置类型(例如int),pass by value往往比pass by reference的效率更高。对于这一点,也同样适用于STL的迭代器和函数对象,因为习惯上,它们都被设计为passed by value。
  • 因为内置类型都相当的小,所以可能有人就会认为,所有小型types都可以使用pass-by-value,甚至当它们是用户自定义的class也一样,这个结论是错误的!因为对象小不代表copy构造函数的代价就不高。有许多对象——包括大多数STL容器——内含的东西只比一个指针多一些而已,但是复制这种对象却需要承担“赋值这些指针所指的每一样东西”。因此,代价也是非常昂贵的。
  • 即使小型对象拥有并不昂贵的copy构造函数,在效率上也可能有差距。某些编译器在对待“内置类型”和“用户自定义类型”的态度上截然不同,即使两者拥有相同的底层表述(underlying representation)。
  • “小型的用户自定义类型不一定通过pass-by-value”的另一个理由是:作为一个用户自定义类型,其大小容易变化。一个type目前虽然比较小,将来却可能会变得比较大,因为其内部实现可能会变化。

总而言之,其他小型的type包括自定义的class,对象本身小不代表copy代价不高;就算代价不高,编译器的不同对待也会导致效率也比较低;并且这些自定义类型大小是容易变化的。

一般而言,我们可以认为:

  • pass-by-value代价不高的唯一对象就是内置类型和STL的迭代器和函数对象。

以至于所其他其他的任何东西,都应当尽量以pass-by-reference-to-const替换pass-by-value。

记住:

  • 尽量以 pass-by-reference-to-const 替换pass-by-value。前者通常比较高效,并可避免切割问题。
  • 以上规则并不适用于内置类型、STL的迭代器和函数对象。对它们而言,pass-by-value 往往比较适当。

条款21

必须返回对象时,别妄想返回其reference。

在上一章中我们了解到,pass-by-value有很多效率方面的问题,因此pass-by-reference可能是一种比较好的方法。
但是!盲目的用reference可能会造成这样的错误:开始传递一些references指向其实并不存在的对象!

举个例子,对于一个用以表现有理数(rational number)的class,内含一个函数用来计算一个有理数的乘积:

1
2
3
4
5
6
7
8
9
10
11
12
class Rational {
public:
Rational(int numerator = 0, int denominator = 1);
//分子numerator和分母denominator
...
private:
int n, d;
friend
const Rational
operator* (const Rational& lhs,
const Rational& rhs);
};

在上面的代码中,这个版本的operator 是以by value的方法返回其计算结果——一个rational对象。对于这样的返回方法,代价如何?
假如说我们进行修改,使用reference进行传递,就不需要付出代价了。

但是:所谓的reference,只是一个名称,代表着某个既有对象。即,它一定是某物的另一个名称。

就像上面的operator* ,如果他返回一个reference,那么后者一定指向某个既有的Rational对象,内含两个Rational对象的乘积。因此,我们不能期望这样一个内含乘积的Rational对象在调用operator* 之前就存在。也就是说:

1
2
3
Rational a(1, 2);       //a = 1/2
Rational b(3, 5); //b = 3/5
Rational c = a * b; //c应该是3/10

期望“原本就存在一个值为3/10的Rational对象”并不合理。如果operator* 要返回一个reference指向这个数值,它就必须自己创建这个Rational对象!


一般来说,函数创建新对象有两种方法:

  • 在stack空间创建
  • 在heap空间创建

如果我们定义一个local变量,就是在stack空间创建对象。根据这个策略,尝试写一下operator*:

1
2
3
4
5
6
const Rational& operator* ( const Rational& lhs,
const Rational& rhs)
{
Rational result(lhs.n * rhs.n, lhs.d * rhs.d); //使用了构造函数实现,但是非常糟糕!
return result;
}

对于上面这个方法,是一个比较糟糕的办法!因为我们的目标是避免使用构造函数,而result却必须用构造函数的方法来进行构造。更严重的是,这个函数返回一个reference指向result,但是result是一个local对象,而local对象在函数退出之前就被销毁了。因此,此时operator* 所指向的Rational,是一个已经被销毁的Rational!于是,此时将会陷入“无定义行为”的困境。

简单总结一句话:任何函数如果返回一个reference指向某个local对象,都会产生必然的错误!


因此,我们考虑在heap内构造一个对象,并返回reference指向它。
Heap-based对象是由new创建的,因此我们需要写一个heap-based operator*,形式如下:

1
2
3
4
5
6
const Rational& operator* ( const Rational& lhs,
const Rational& rhs)
{
Rational* result = new Rational(lhs.n * rhs.n, lhs.n * rhs.d); //更为!糟糕的写法!
return *result;
}

在上面的代码中,我们依然需要付出一个“构造函数调用”的代价,因为分配获得的内存将以一个适当的构造函数完成初始化动作。
然而,此时还有一个更为严重问题:谁应该为被new出来的对象实施delete?

即使我们十分谨慎,还是会在合情合理的使用下,造成内存泄漏:

1
2
Rational w, x,y,z;
w = x * y * z; //与operator*(operator*(x, y), z)相同

在上面的代码中,同一个语句调用了两次operator*,因此使用了两次new,因此也就需要两次delete。但是,并没有合理的办法让operator*的使用者进行哪些delete调用,因为没有合理的办法让他们取得operator* 返回的references背后隐藏的那个指针。

这势必会造成内存泄漏!


无论是on-the-stack,还是on-the-heap的做法,都因为对operator* 返回的结果调用构造函数而产生了问题,而最开始的目标就是避免如此的构造函调用动作!于是,另一个想法则是基于:

  • 让operator* 返回的reference指向一个被定义于函数内部的static Rational对象。
1
2
3
4
5
6
7
const Rational& operator* ( const Rational& lhs,
const Rational& rhs)
{
static Rational result; //还是很糟糕!定义static对象,该函数将返回它的reference
result = ... ; //将lhs乘以rhs,并将结果置于result之内
return result;
}

上面的代码之所以糟糕,是因为如果对于以下的使用代码:

1
2
3
4
5
6
7
8
9
10
11
12
bool operator==(const Rational& lhs,
const Rational& rhs); //一个针对Rational而写的operator==
Rational a, b, c, d;
///
if ((a * b) == (c * d))
{
乘积相等所执行的动作
}
else
{
乘积不等所执行的动作
}

出现的问题是:无论a, b, c, d的值是多少,表达式((a * b) == (c * d))总是被判定为True!

让我们将上述的if判断语句写成等价的函数形式:

1
if (operator==(operator*(a, b), operator*(c, d))

上面这句代码,可以看到在operator==被调用之前,已经有两个operator* 被调用了,并且每一个都返回reference指向operator* 内部定义的static Rational对象。因此,operator==被要求将“operator* 内的static Rational对象值” 拿来和“operator* 内的static Rational对象值” 进行比较,这自然就是相等了。

值得注意的是,两次的operator*的调用确实是改变了static Rational对象值,但是由于他们都是返回reference,因此调用段看到的永远是static Rational对象的“现值”。

另外,如果创建一个static array保存这些static对象呢?一方面这个数组的大小很难选择(太小不够用,太大降低效率),一方面每个对象都会在函数第一次调用时构造完成,调用n个构造函数和最后有n个析构函数。接着,为了把结果值写入array,又要调用赋值操作,很多时候赋值操作相当于一个拷贝构造和一个析构函数,情况就更恶劣了。


于是,一个“必须返回新对象”的函数的正确写法是:直接让这个函数返回一个新对象。

1
2
3
4
incline const Rational operator* ( const Rational& lhs, const Raitonal& rhs)
{
return Rational(lhs.n * rhs.n, lhs.d * rhs.n);
}

当然了,operator*返回值的构造成本和析构成本是必须支出的,但是这只是获得正确行为的小小代价。

总之:

  • 当我们必须在“返回一个reference和返回一个object”之间进行选择时,选择行为正确的那一个。

记住:

绝不要返回pointer或reference指向一个local stack对象,或返回reference指向一个heap-allocated对象。或返回pointer或reference指向一个local static对象而有可能同时需要多个这样的对象。条款4已经为“在单线程环境中合理返回reference 指向一个local static对象”提供了一份设计实例。

条款22

将成员变量声明为private。

总结下来主要有三点理由:

  • 语法一致性:如果public的都是函数,用户在调用成员时都加上小括号就行了,不必再考虑这是变量还是函数(要不要加括号);
  • 精确控制:将变量放private,可以继而通过函数实现精准地控制,如这些变量读写访问权限;
  • 封装:public意味着不封装,不封装意味着不可改变。如果将成员变量隐藏,那么暴露出来的函数即使内部实现改变(适应不同情况,可能用不同成员变量)也不会对客户造成多大的困扰,最多只需要重新编译。这样的封装,使得class的约束条件容易得到维护,保留了日后变更实现的权力。
    • 改变public事物总是收到束缚的,因为会破坏许多客户码(它们用暴露出来的成员实现其他功能等等)。
    • protected虽然不能被外部对象访问,但可以被派生类本身访问,因此也是不封装的,改变它会使得派生类代码被破坏。
    • 也就是说:private提供封装,其他不提供封装。

记住:

  • 切记将成员变量声明为private。这可赋予用户访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证,并提供class作者以充分的实现弹性。
  • protected并不比public更具封装性。

条款23

宁以non-member&non-friend替换member函数。

假设有一个class用来表示网页浏览器。这样的class可能提供的众多函数中:

1
2
3
4
5
6
7
8
class WebBrowser {
public:
...
void clearCache();//清除下载元素高速缓存区
void clearHistory();//清除访问过的URLs的历史记录
void removeCookies();//清除系统中的所有cookies
...
};

因此,用户可能会觉得使用一个操作来执行这些任务,因此WebBrowser也提供这样一个member函数:

1
2
3
4
5
class WebBrowser {
public:
...
void clearEverything(); //调用上述的三个函数
};

同时,这个机能也可以通过一个non-memebr函数调用适当的member函数而提供:

1
2
3
4
5
6
void clearBrowser (WebBrowser& wb)
{
wb.clearCache();
wb.clearHistory();
wb.removeCookies();
}

那么,对于上面两个办法,哪一种较好呢?

首先,对于面向对象守则的要求,数据及操作数据的那些函数应该被捆绑在一起。这就意味着使用member函数可能是一个比较好的选择。然而事实上并非如此。

  • 面向对象守则要求数据应该尽可能地被封装,而与直观相反的是,member函数clearEverything所带来的封装性比non-member函数clearBrowser要低。
  • 提供non-member函数可允许对WebBrowser相关机能有较大的包裹弹性(packaging flexibility),而那最终导致较低的编译相依度,增加WebBrowser的可延伸性。
  • 因此,许多方面non-member做法比member做法好。

从封装开始讨论。

如果某些东西被封装,它就不再可见。越多的东西被封装,就越少的人可以看见。而越少的人可以看到它,我们就有越大的弹性去改变它,因为我们的改变仅仅直接影响看到改变的那些人事物。

  • 因此,越多的东西被封装,我们改变这些东西的能力就越大。简而言之,封装使得我们能够改变事物而只影响有限的用户。

越少的代码可以看到数据(即访问到它),越多的数据就可以封装,而我们就越能自由地改变对象数据,例如改变成员变量的数量、类型等。

  • 简而言之,越多的函数可以访问这个数据,它的封装性就越低。

在上一个条款22中可以得知,成员变量应该是private,因为如果它们不是private,就会有无限量的函数可以访问到它们,它们也就毫无封装性。

  • 能够访问private成员变量的函数只有class的member函数加上friend函数。

对于member函数,它可以访问class内的:private数据、private函数、enums、typedefs等等。而对于non-member函数,它无法访问上述的任何一个。如果我们需要在member函数和non-member函数中选择,而且两者提供相同的机能,则:

  • 导致较大封装性的是non-member non-friend函数,因为它并不会增加“能够访问class内的private”的函数数量。

因此,这也就解释了为什么clearBrowser(non-member non-friend函数)比clearEverything(member函数)更受欢迎:它导致WebBrowser class有有较大的封装性。

  • 这个论述仅适用于non-member non-friend函数。friends函数对class private成员的访问权利和member函数相同。因此,从封装的角度来看,这里的选择关键并不在于member和non-member函数之间,而在于member和non-member&non-friend函数之间。当然,封装并非我们的唯一考量。当我们考虑隐式类型转换时(条款24),就应该在member和non-member函数间进行选择了。
  • 只因为在意封装性而让函数“变成class的non-member”,并不意味着它“不可以是另一个class的member。”例如,我们可以令clearBrowser称为某个工具类(utility class)的一个static member函数,只要它不是WebBrowser的一部分(或者称为其friend),就不会影响WebBrowser的private成员封装性。

在C++中,比较自然地做法是:让clearBrowser成为一个non-member函数,并且位于WebBrowser所在的同一个namespace(命名空间)中。

1
2
3
4
5
namespace WebBrowserStuff {
class WebBrowser { ... };
void clearBrowser(WebBrowser& wb);
...
}

这不仅仅是看起来自然而已,namespace和class不同,前者可以跨越多个源码文件而后者不可以(namespace提供功能上的切割)。这一点很重要,因为clearBrowser这个函数是个“提供便利的函数”,如果它既不是member或friend。它就没有对WebBrowser的特殊访问权利,也就不能提供其他机能。没有这个函数,用户可以通过自行调用三个函数来清除网页。

这样,一个像WebBrowser这样的class可能会拥有大量的便利函数,某些与书签有关,某些与打印有关,某些则与cookie管理有关。而大多数情况下用户只对其中某一个或某几个感兴趣。

因此,分离它们的最直接的办法就是讲某一个相关函数声明在一个头文件内,将另一个相关函数声明在另一个头文件中且使用相同的命名空间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//头文件webbrowser.h,这个头文件针对class WebBrowser自身以及WebBrowser核心机能
namespace WebBrowserStuff {
class WebBrowser { ... };//类的实现在这里,类不允许跨文件(切割)
... //核心机能,例如几乎所用用户都会用到的non-member函数
}

//头文件webbrowserbookmarks.h
namespace WebBrowserStuff {
... //与书签相关的便利函数,注意只是这些便利函数
}

//头文件webbrowsercookies.h
namespace WebBrowserStuff {
... //与cookie相关的便利函数
}

这正是C++标准程序库的组织方式。标准程序库并不是拥有单一、整体、庞大的< C++StandardLibrary >头文件并在其中内含std命名空间内的每一样东西,而是有数十个头文件(< vector >, < algorithm >, < memory> 等等),每个头文件声明std的某些机能。如果一个用户只想使用vector的相关机能,他并不需要去#include <memory>于是,这就允许用户只对他们所用的那一小部分系统形成编译相依。

然而:这种切割并不使用于class成员函数,因为一个class必须整体定义,不能被分割为片段,因此这种时候该使用namespace。

简而言之,将所有便利函数放在多个头文件内但是隶属于同一个命名空间,意味用户可以轻松扩展这一组便利函数。他们需要做的仅仅是添加更多non-member non-friend函数到这个命名空间中。

  • 这一点是class无法提供的另一个性质,因为class定义式对于用户而言是不可扩展的。尽管用户可以派生出新的classes,但是derived classes无法访问base class中被封装(即private)成员,于是此时的“扩展机能”拥有的只是次级身份。
  • 此外,并非所有的class都被设计作为base classes。

记住:

  • 宁可拿non-member non-friend函数替换member函数。这样做可以增加封装性、包裹弹性和机能扩充性。
  • 附:大量的便利函数可以使用相同的命名空间而放在不同的头文件里,这样用户可以根据自己对功能的需求使用头文件,减少编译相依性。其中该有个核心的头文件,提供核心便利函数以及类的实现。

条款24

若所有参数皆需类型转换,请为此采用non-member函数。

令class支持隐式类型转换通常是一个糟糕的选择。但是,这条规则也有例外,最常见的就是建立数值类型时。假设对于一个class用来表示有理数,因此允许整数“隐式转换”为有理数看起来其实是挺合理的:

1
2
3
4
5
6
7
8
9
10
class Rational {
public:
Rational(int numerator = 0, //构造函数刻意不是explicit
int denominator = 1); //允许int-to-rational进行隐式转换,前提是构造函数有默认参数
//这样就可以把int解释成分子的参数,而分母使用默认值1
int numerator() const; //分子的访问函数
int denominator() const; //分母的访问函数
private:
...
};

接着,对于算数运算的实现,到底应该使用member函数、non-member函数,还是non-member friend函数来实现?首先,对于operator* 的实现,虽然在条款23中指出,将函数放进相关class内又是会与面向对象守则发生矛盾,但先暂时不考虑,先看一下将operator* 写成Rational 成员函数的写法:

1
2
3
4
5
class Rational {
public:
...
const Rational operator* (const Rational& rhs) const;
};

这种设计,可以以很方便的方式实现相乘:

1
2
3
4
Rational oneEight(1, 8);
Rational oneHalf(1, 2);
Rational result = oneHalf * oneEight; //成功!
result = result * oneEight; //成功!

暂时看上去,是可行的。但是如果我们此时用两个不同类型的数据进行相乘——比如,一个Rational和int相乘,就可能会出现问题:

1
2
result = oneHalf * 2;   //成功!
result = 2 * oneHalf; //错误!

为什么会出现这样的错误??当我们以对应的函数形式重写上述两行代码:

1
2
result = oneHalf.operator*(2);  //成功!
result = 2.operator*(oneHalf); //错误!

因此,错误就显而易见了:

  • oneHalf是一个内含operator* 函数的class的对象,因此没有问题。
  • 整数2并没有对应的class,也就没有operator* 成员函数。

此时,编译器也会尝试在命名空间内或在global作用域内调用以下形式的non-member operator*:

1
2
result = operator*(2, oneHalf);     //错误!
//在此例中,并不存在这样一个接受int和Rational作为参数的non-member operator*, 因此会查找失败。

在这里,上面第一次有参数2,之所以成功,是因为这里发生了所谓的隐式类型转化(implicit type conversion)。

编译器知道此时确实是传递了一个int,而函数需要的却是Rational;但编译器同时也知道,只要它调用Rational构造函数并赋予传递来的int,就可以构造出适当的Rational出来。换句话说,此时的调用动作在有点像以下的形式:

1
2
const Rational temp(2);     //根据2建立一个暂时性的Rational对象,2作为分子的参数,分母使用默认值
result = oneHalf * temp; //等同于oneHalf.operator*(temp)

这也只是因为涉及到了non-explicit构造函数,编译器才会这样去实现。如果 Rational的构造函数是explicit,下面两条语句都是错误的:

1
2
result = oneHalf * 2;   //错误!无法将2转换位Rational
result = 2 * result; //一样的错误!

此时我们可以看到,这就很难让Rational class支持混合式算数运算了。


  • 只有当参数被列于参数列(parameter list)内,这个参数才是隐式类型转换的合格参与者。

    1
    2
    3
    C++的参数列表有函数的参数列表、宏定义的参数列表、模板的类型参数列表等。
    参数列表又可以分为形式参数列表和实际参数列表。
    例如: 在定义函数时函数头部所列的就是形式参数列表,在调用函数时所列的就是实际参数列表。

这也就解释了为何第一次可以通过编译,而第二次不可以:因为第一次的调用伴随着一个放在参数列内的参数,第二次则没有(operator*函数没有两个放在参数列参数的版本)。实际上,是由于member函数自动使用*this占用第一个参数。

因此,最终的解决方案就出现了:

  • 让operator* 成为一个non-member函数,并允许编译器在每一个实参身上执行隐式类型转换:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Rational {            //并不包含operator*的定义
...
};

const Rational operator*(const Rational& lhs, //构成了一个non-member函数
const Rational& rhs)
{
return Rational(lhs.numerator() * rhs.numerator(),
lhs.denominator() * rhs.denominator());
}
Rational oneForth(1, 4);
Rational result;
result = oneForth * 2; //成功了!
result = 2 * oneForth; //成功了!!

此时,问题得到了顺利的解决,不过仍然有一点需要考虑:

  • operator* 是否应该是Rational class的一个friend函数呢?

就这个例子而言,答案是否定的!因为operator* 完全可以由Rational的public接口完成任务,上面的代码也是这样去实现的。(public的构造函数和分子分母的访问函数)于是,这也又引发了一个重要的结论:

  • member函数的反面是non-member函数,而不是friend函数。

有很多程序员会有这样的误解,如果一个“与某class相关”的函数不应该成一个member,那么它就一定要称为friend,这是一个错误的理解。无论何时如果可以避免使用friend函数,就应该去避免。虽然friend有其正当性,但下面的结论依然成立:

  • 不能只因为函数不该成为member,就自动让它成为friend。可以用class中public的成员函数作为接口,供non-member函数访问class相关的内容。

记住:

如果你需要为某个函数的所有参数(包括this指针所指的哪个隐喻参数)进行类型转换,那么这个函数必须是个non-member。

附:因为隐式转换需要匹配参数列,对于成员函数,第一个参数是*this,使得可能不能匹配转换。

条款25

考虑写出一个不抛异常的swap函数。

swap是一个有趣的函数。

原本它只是STL的一部分,而后则成为异常安全性编程(exception-safe programming)的主体,以及后来用于处理自我赋值可能性(条款11)的一个常见机制。因此,swap的实现是非常重要的。

所谓的swap(置换)两对象的值,指的就是将两对象的值彼此赋予对方。在默认的情况之下,swap动作可以由STL提供的swap算法来实现:

1
2
3
4
5
6
7
8
9
namsespace std {
tempate<typename T>
void swap(T& a, T& b)
{
T temp(a);
a = b;
b =temp;
}
}

只要类型T支持copying(通过copy构造函数和copy assignment操作符来完成),缺省的swap实现代码就会自动置换类型为T的对象,我们并不需要额外的工作。这种缺省的实现比较简单,涉及到了三个对象的复制:

  • a复制到temp
  • b复制到a
  • temp复制到b

但是对于某些类型而言,这些复制动作并没有必要!

其中最主要的即“以指针指向一个对象,内含真正数据”的类型。这种类型最常见的表现形式就是“pimpl手法”(pointer to implementation)如果以这种手法设计Widget class:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class WidgetImpl {
public:
...
private:
int a, b, c; //可能有许多数据,意味着复制时间很长
std::vector<double> v;
...
};

class Widget { //该class使用pimpl手法
public:
Widget(const Widget& rhs); //复制Widget时,令它复制其WidgetImpl对象
Widget& operator=(const Widget& rhs) //operator=的实现见条款10~12
{
...
*pImpl = *(rhs.pImpl);
...
}
...
private:
WidgetImpl* pImpl; //指针,所指的对象就是内含Widget数据
};

一旦我们需要置换两个Widget对象的值,我们唯一需要做的就是置换其pImpl指针而已。
但是,缺省的swap并不知道这一点!它不仅会复制三个Widgets,还会复制三个WidgetImpl对象!效率一下子就变得很低了。


我们希望告诉std::swap,当Widgets被置换时,真正应该做的就是置换内部的pImpl指针。而实现这一想法的做法是

  • 将std::swap针对Widget特化。

下面的代码是思路的实现,但是无法通过编译:

1
2
3
4
5
6
7
namsespace std {        //这是std::swap针对“T是Widget”的特化版本,并不能通过编译
tempate<>
void swap<Widget>(Widget& a, Widget& b)
{
swap(a.pImpl, b.pImpl); //置换Widget时,只需要置换它们的pImpl指针即可
}
}

tempate<>:表示它是std::swap的一个全特化(total template specialization)版本;函数名之后的:表示这一特化版本会针对“T是Widget”而设计的;换句话说,一般性的swap template施加于Widget身上便会启用这个版本。

通常而言,我们不能改变std命名空间内的任何东西,但是可以为标准templates(如swap)制造(全)特化版本,使得它专属于我们自己定义的class(例如Widget)。上面的代码也正是这样去实现的。

而之所以上面的代码无法通过编译,是因为:

  • 它企图访问a和b内的pImpl指针,但这个指针是private。

因此,一个解决办法则是:

  • 令Widget声明一个名为swap的public成员函数,来去做真正的置换工作,然后将std::swap特化,令它调用该成员函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Widget {          //与前面相同,唯一的差别就是增加swap函数
public:
...
void swap(Widget& other)
{
using std::swap; //这个声明是非常必要的
swap(pImpl, other.pImpl); //若要置换Widget,就置换其pImpl指针
}
...
};

namespace std { //修订后的std::swap特化版本
tempate<>
void swap<Widget>(Widget& a, Widget& b)
{
a.swap(b); //如果要置换Widgets,调用其swap成员函数
}
}

在上面的代码中,不仅能够通过编译,而且还与STL容器有一致性:

  • 所有STL容器也都提供有public swap成员函数和std::swap特化版本(用以调用前者)

然而,假设Widget和WidgetImpl都是class template而并非class,也许可以尝试将WidgetImpl内的数据类型加以参数化:

1
2
3
4
5
template<typename T>
class WidgetImpl { ... };

temolate<typename T>
class Widget { ... };

在Widget内放一个swap成员函数就像前面一样简单,但是在特化std::swap时却会遇到问题!

1
2
3
4
5
6
namespace std {
template<typename T>
void swap< Widget<T> > ( Widget<T>& a, //错误!
Widget<T>& b)
{ a.swap(b); }
}

虽然这样看起来是合理的,然而却并不合法。
当我们企图偏特化(partially specialize)一个function template(std::swap),而C++只允许对class template偏特化,在function template身上偏特化是不可以的,只能全特化。

当我们打算偏特化一个function template时,一般的做法是简单地为它添加一个重载模板:

1
2
3
4
5
6
namespace std {
template<typename T> //std::swap的一个重载版本
void swap(Widget<T>& a, //需要注意的是,swap后面没有" <...> "
Widget<T>& b) //但是这样也是不合法的!
{ a.swap(b); }
}

般而言,我们是可以重载function template的,但是std是一个特殊的命名空间,因此管理规则也比较特殊:

  • 使用者可以全特化std内的template,但是不可以添加新的template(或者class、function以及其他东西)

因此,所谓的“禁止”,其实添加新东西到std里是可以编译的,但是这样的行为是没有明确定义的。如果我们希望程序有可预期的行为,就不要添加任何新东西到std之中。


我们不要添加任何新东西到std内。但我们还是需要一个办法,以提供高效的template特定版本的swap。解决办法是:

  • 依然是声明一个non-member swap,让它调用member swap,但不再将那个non-member swap声明为std::swap的特别版本或重载版本。(不需要为std的swap来全特化了,只做一个非成员函数的swap来调用)

因此,为了简化起见,假设Widget的所有相关机能被置于命名空间WidgetStuff,于是:

1
2
3
4
5
6
7
8
9
10
11
12
13
namespace WidgetStuff {
... //模板化的WidgetImpl等等
template<typename T> //和前面一样,内含swap成员函数
class Widget { ... };
...

template<typename T> //non-member swap函数
void swap(Widget<T>& a, //这里并不属于std命名空间
Widget<T>& b)
{
a.swap(b);
}
}

于是,当我们置换两个Widget对象,因而调用swap,C++的名称查找法则(name lookup rules;更具体的说是所谓argument-dependent lookup或Kobeig lookup法则)将会找到WidgetStuff内的Widget专属版本。

也可以不使用额外的命名空间,但何必在gloabal命名空间内塞满各式各样的class、template、function等等呢?


然而,虽然上面的做法对于class和class template都行得通,但我们还是应该为class(非template的)特化std::swap。

所以,如果我们想让“class 专属版”的swap在尽可能多的语境下被调用,我们就应该同时在该class所在命名空间内写一个non-member版本以及一个std::swap特化版本。也即为class写三个swap:成员函数版,非成员函数版和std全特化版本。后两个版本调用第一个版本。


上面所说的swap,一直是从我们自身角度去考虑。如果我们从用户的角度来看,对swap进行定义也非常有必要。假设正在写一个function template,其内需要置换两个对象值:

1
2
3
4
5
6
7
template<typename T>
void doSomething(T& obj1, T& obj2)
{
...
swap(obj1, obj2);
...
}

此时,调用了swap,但是调用的是哪一个一般化版本?

  • std既有的一般化版本?
  • 某个可能存在的特化版本?
  • 存在的T专属版本而且可能存在与某个命名空间内(非std内)

我们希望的是调用T专属版本,并在该版本不存在的情况下,再去调用std内的一般化版本:

1
2
3
4
5
6
7
8
template<typename T>
void doSomething(T& obj1, T& obj2)
{
usint std::swap; //令std::swap在此函数内可用
...
swap(obj1, obj2); //为T型对象调用最佳swap版本
...
}

一旦编译器看到了对swap的调用,它们便查找适当的swap并加以调用。C++的名称查找发着会确保将找到global作用域或者T所在的命名空间内的任何T专属的swap。

  • 如果T是Widget并位于命名空间WidgetStuff内(或者在global空间,如果你是在那实现的话),编译器就会使用“实参取决的查找规则”(argument-dependent lookup)找出WidgetStuff内的swap。
  • 如果没有T专属的swap存在,编译器就会使用std内的swap——由using std::swap这条语句,使得这个选择被展现。
    • 当然,如果已经针对T将std::swap进行了全特化,这个全特化版本也直接会被优先使用(优先于泛型版本的std::swap)。

因此,令适当的swap被调用是比较容易的。但需要小心的是:不要添加额外的修饰符,这样会影响C++挑选适当的函数:

1
std::swap(obj1, obj2);      //错误的swap调用方式

上面这个举动,会迫使编译器只认std内的swap,因而不再可能调用一个定义于其他地方的适当T专属版本。


到目前为止,这三部分已经讨论了:

  • default swap
  • member swap
  • non-member swap
  • std::swap 特化版本
  • swap的调用

因此,做一个总结:

  • 首先:如果swap的缺省实现(std版本)对我们的class或class template提供可接受的效率,那么我们并不需要做其他的事情。
  • 其次:如果swap的缺省版本效率不足(基本上就是因为class或者class template使用了某种pimpl手法),则三部曲:
    • 1.提供一个public swap成员函数,让它高效地置换对应类型的两个对象值。这个函数绝不能抛出异常(其他函数是调用它)。
    • 2.在我们的class或template所在的命名空间内(最好是用特殊空间,但在global空间也不会编译错误)提供一个non-member swap,并令它调用上述swap成员函数。
    • 3.如果我们正在编写一个class(而非class template),就需要为我们的class特化std::swap,并令它调用我们的swap成员函数。否则不需要这一步。
  • 最后,如果我们调用swap(在最高的层次调用),请确定包含一个using声明式,以便让std::swap在我们的函数内部可以曝光可见,然后不加任何namespace修饰符,直接去调用swap。

还有一点:

  • 成员版本的swap绝对不可以抛出异常!

原因在于,swap的一个最好的应用就是为了帮助class(和class template)提供强烈的异常安全性(exception-safety)保障。当然,这一约束只施行于成员版!不可实施于非成员版(也不必,因为非成员版就是调用成员版),因为swap缺省版本是以copy构造函数和copy assignment操作符为基础的,在一般情况下是允许抛出异常的。

因此,当我们写一个自定义版本的swap时,往往需要提供以下两点:

  • 高效置换对象值的办法
  • 不抛出异常

一般而言,上面这两个特性是连在一起的,因为高效率的swap几乎总是基于对内置类型的操作(例如pimpl首发的底层指针),而内置类型上的操作绝不会抛出异常。

记住:

  • 当std::swap对你的类型效率不高时,提供一个swap成员函数,并确定这个函数不抛出异常。
  • 如果你提供一个member swap,也该提供一个non-member swap用来调用前者。对于classes(而非templates),也请全特化std::swap。
  • 调用swap时应针对std::swap使用using声明式,然后调用swap并且不带任何“命名空间资格修饰”。
  • 为“用户定义类型”进行std templates全特化是好的,但千万不要尝试在std内加入某些对std而言全新的东西。

条款26

尽可能延后变量定义式的出现时间。

两个理由:

  • 定义好的变量可能没有使用,这样就多了一次构造和析构。这个没有使用可能是由于提前返回、抛出异常造成的。所以,尽可能延后变量的定义直到要使用为止;
  • 有时变量先定义了但还不知道初值,而是等后面再赋值。这对于大多数类(可以说除了内置类型以外的其他类)来说是低效的,因为定义再赋值使用一次(默认)构造和一次赋值,效率往往比一次(带参)构造(直接在构造时指定初值)来得差。所以,尽可能延后变量定义直到确定初值为止。

关于第二点,假如在一个循环中使用一个变量,到底应不应该在循环体中定义呢?

  • 在循环体外定义:1个构造函数+1个析构函数+n个赋值
  • 在循环体内定义:n个构造+n个析构

如果一个赋值的成本低于一个构造+一个析构,那么往往选择第一种做法,尤其当n很大的时候。否则第二种做法好。此外做法1造成变量的作用域比做法2要大,有时对程序的可理解性和易维护性造成冲突。因此除非:

  • 赋值比构造+析构成本低;
  • 正在处理代码中效率高度敏感的部分。

否则应该使用做法2。

记住:

尽可能延后变量定义式的出现。这样做可增加程序的清晰度并改善程序效率。

条款27

尽量少做转型动作。

C++的设计目标之一是,保证“类型错误”绝对不可能发生。理论上如果你的程序很“干净地”通过编译,就表示它并不企图在任何对象身上执行任何不安全、无意义、愚蠢荒谬的操作。这是一个极具价值的保证,可别草率的放弃它。

不幸的是,转型破坏了类型系统。那可能导致任何种类的麻烦,有的容易辨识,有些非常隐晦。C、java、c#语言中可能转型是必要的、无法避免的,相比于C++也比较不那么危险。但是C++中,应该尽量少的做转型,C++中使用转型比较危险,应该尽量将转型动作使用不转型的手法给化解掉。

有三种转型的语法:

1
2
3
4
5
//形式一:C语言风格的转型语法:
(T)expression //将expression转换为T类型

//形式二:函数风格的转型:
T(expression) 将expression转换为T类型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//形式三:C++风格的转型语法
const_cast<T>(expression);//const->non const
//const_cast 用来将对象的const属性去掉,功能单一,使用方便.

dynamic_cast<T>(expression);
//dynamic_cast 用于继承体系下的"向下安全转换",通常用于将基类对象指针转换为其子类对象指针,
//它也是唯一一种无法用旧式转换进行替换的转型,也是唯一可能耗费重大运行成本的转型动作.

reinterpret_cast<T>(expression);
//低级转型,结果依赖与编译器,这因为着它不可移植,我们平常很少遇到它,通常用于函数指针的转型操作.

static_cast<T>(expression);
//static_cast 用来进行强制隐式转换,我们平时遇到的大部分的转型功能都通过它来实现.
//例如将int转换为double,将void*转换为typed指针,将non-const对象转换为const对象,反之则只有const_cast能够完成.

注意:形式一、二并无差别,统称旧式转型,形式三称为新式转型。


新式转型的优点:

  • 在代码新式转型容易被识别出来(无论是人工识别还是使用工具如grep),因而简化“找出类型系统在哪个点被破坏”的过程(简化找错的过程)。
  • 各种转型动作的目标越窄化,编译器越能判断出出错的运用。例如:如果你打算将常量性去掉,除非使用新式转型的const_cast否则无法通过编译。

旧式转型的唯一适用场景( 对于作者本人来说的唯一):

唯一使用旧式转型的时机是,当调用一个explicit构造函数将一个对象传递给一个函数时。例如:

1
2
3
4
5
6
7
8
9
class Widget{
public:
explicit Widget(int size);//禁用隐式转换,下面就不可以只传15这个参数,需要显式转换。
//如果没有explicit,是可以只传15的,会自动执行隐式转换,把15放入构造函数里去构造。
...
};
void doSomething(Widget& w);
doSomething(Widget(15)); //"旧式转型"中的函数转型
doSomething(static_cast<Widget>(15));//"新式转型"

从某个角度来说,蓄意的“对象生成”动作不怎么像“转型”,因此没使用新式转型。但是,其他情况下(或者所有情况下),即使觉得旧式转型合理,也最好使用新式转型。


  • 容易产生的误解:请不要认为转型什么都没做,其实就是告诉编译器把某种类型视为另一种类型。实际上,任何一种转型动作往往真的令编译器额外地编译出运行期间执行的代码。例如将int转型为double就会发生这种情况,因为在大部分的计算器体系结构中,int的底层表述不同于double的底层表述。

  • 转型动作导致编译器在执行期间编译出不同的码的另外一个例子:单一的对象可能拥有一个以上的地址(例如:”以base*指向它”时的地址和”以Derived*指向它”时的地址不同,因为这时会有一个偏移量在运行期间施加在Derived*身上,用以取得正确的base*的指针值。偏移量是因为:类成员本身的位置是确定的,但是对于派生类、基类来说,指向的起始位置不一样)。实际上一旦使用多重继承,这事几乎一直发生。即使在单一继承中也可能发生。

    • 有了偏移量这个经验后,我们也不能做出“对象在C++中如何布局”的假设。因为对象的布局方式和它们的地址计算发式随着编译器的不同而不同,这就以为着写出”根据对象如何布局”而写出的转型代码在某一平台上行得通,在其它平台上则不一定。很多程序员历经千辛万苦才学到这堂课。
  • 转型动作容易写出似是而非的代码:很多框架都需要在派生类的virtual函数中第一个动作就调用基类的版本的函数:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    class Window{
    public:
    virtual void onResize(){...}
    ...
    };
    class SpecialWindow:public Window{
    public:
    virtual void onResize(){
    static_cast<Window>(*this).onResize();//调用基类的实现代码
    ... //这里进行SpecialWindow的专属行为.
    }
    ...
    };

    上述代码看着似乎合情合理,但是实际却是错误的。错在转型语句。为什么错呢?

    首先它确实执行了多态,调用的函数版本是正确的,但是由于做了转型,它并没有真正作用在派生类对象身上,而是作用在了派生类对象的基类部分的副本身上,改动的是由于转型产生的那份基类副本(因为先产生副本,然后调用副本的函数)。如果后面的代码是对派生类更改的话,导致的最终结果就是:当前对象的基类部分没有被改动,但是派生类部分却被真实地改动了。

    解决的方法是拿掉转型,直接调用:

    1
    2
    3
    4
    void SpecialWindow::onResize(){
    Window::onResize(); //此时才是真正的调用基类部分的onResize实现.
    ... //同上
    }

关于dynamic_cast,首先要有一个认识,就是dynamic_cast的实现版本执行速度相当的慢。尤其是在深度继承和多重继承中,速度更慢。

  • 何时需要dynamic_cast:通常当你想在一个你认定为derived class对象上执行derived class操作函数时,但是你的手上只有一个指向base 的指针或引用时,你会想到使用dynamic_cast进行转型

有两个一般性做法可以避免使用dynamic_cast(并非总是可以,但许多情况下可行):

  • 方法一:如果有多个对象,使用容器(如vector),并在其中存储直接指向derived class对象的指针(通常是智能指针),这样就避免了上述需求。
  • 方法二:在base class内提供virtual函数做你想对各个派生类想做的事情。这样可以使得你通过base class 接口处理“所有可能之各种派生类”。因为virtual函数使得编译器不识别指针类型来选择函数,而是看运行期实际的对象类型来选择函数。

一连串dynamic_cast的代码又大又慢,而且基础不稳,因为每次继承体系一有改变,所有这种代码必须再次进行检查看看是否需要修改。例如假如新的派生类,就要加新的分支。这样的代码应该使用“基于virtual函数调用”的东西取而代之。

最后,完全不用转型是不切实际的。但是我们应该尽量避免转型。就像面对众多蹊跷可疑的构造函数一样,我们应该尽可能隔离转型动作,通常是把它隐藏在某个函数内,函数的接口会保护调用者不受函数内部任何肮脏龌龊的动作的影响。

记住:

  • 如果可以,尽量避免转型,特别是在注重效率的代码中避免dynamic_casts。如果有个设计需要转型动作,试着发展无需转型的替代设计。
  • 如果转型是必要的,试着将它隐藏于某个函数背后。客户随后可以调用该函数,而不需要将转型放进他们自己的代码内。
  • 宁可使用c++-style(新式)转型,不要使用旧式转型。前者很容易辨识出来,而且也比较有着分门别类的职掌。

条款28

避免返回handles指向对象内部成分。

reference、指针、迭代器系统都是所谓的handles(号码牌,用来获得某个对象)。函数返回一个handle而被用户得知,随之而来的便是“减低对象封装性”的风险。它也可能产生虽调用const成员函数却造成对象状态被更改的风险。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Point{
public:
Point(intx,inty);
voidsetX(intnewVal);
voidsetY(intnewVal);
};

struct RectData{
Pointulhc;
Pointlrhc;
};

class Rectangle{
public:
Point& upperLeft()const{returnpData->ulhc;}
Point& lowerRight()const{returnpData->lrhc;}
...
private:
std::tr1::shared_ptr<RectData>pData;
};

上述代码便是自相矛盾的一个例子。point 类是一个代表点的类,RectData代表一个矩形的结构,Rectangle类则代表一个矩形,该类能够返回表示矩阵的左上和右下的两个点。

由于这两个函数为const的,因此所要表达的意思就是,返回矩阵的两个点,但是不能修改他们。但是又由于返回的是点的reference形式,因此通过reference,实际是可以改变返回的点的数据的(可以作为左值修改)。因此,造成了自相矛盾。问题的原因就是,函数返回了handle。

这进而说明:

  • 成员变量的封装性最多等于“返回其reference”的函数的访问级别。即使数据本身被声明为private的,但是如果返回他们的reference的函数是public的,那么数据的访问权限就编程public了。
  • 如果const成员函数传出一个reference(返回外部对象的引用),后者所指数据又不在自身对象内,那么这个函数的调用者可以修改此数据。(这是 bitwise constness 带来的后果。)
    • 如果返回的是对象自身的数据,const的限制会强制使得数据成员类型为const(这样使得数据不能改变),这样就不能返回该数据的引用,因为类型不匹配了。

上述代码的改进版本:在返回handles 的成员函数前加const。这便解决了自相矛盾问题:

1
2
3
4
5
6
7
8
class Rectangle{
public:
const Point& upperLeft()const{returnpData->ulhc;}
const Point& lowerRight()const{returnpData->lrhc;}
...
private:
std::tr1::shared_ptr<RectData>pData;
};

上述代码在其他场景下可能存在的问题:虚吊问题。所谓虚吊问题,就是指针指向了一个不复存在的对象。最常见的问题来源就是函数返回值,例如,某个函数返回GUI对象的外框,是一个矩形形式:

1
2
3
4
5
class GUIObject{};
const Rectangle boundingBox(const GUIObject &obj);//返回值为const,避免没有意义的右值赋值
//现在,客户可能这么使用。
GUIObject *pgo;
const Point *pUpperLeft= &(boundingBox(*pgo).upperLeft() );

boundingBox 函数传入一个GUI对象,它返回一个GUI的外框,即是一个矩形,然后获取这个矩形的右下方的点,并使用一个指针指向它。而函数的返回值是一个临时的对象(by value),即这个矩形是一个临时的对象,当这个语句执行结束后,矩形对象被销毁,因此其内部的点也被销毁,而此时pUpperLeft指向了一个被销毁的点。就形成了所谓的虚吊。

这就是为什么函数如果返回一个handle代表对象内部成分总是危险的原因。不论这个handle是个指针或迭代器或引用,也不论这个handle是否为const(指返回值),也不论返回handle的成员函数是否为const。这里的唯一关键是,有个handle被传出去了,一旦如此你就是暴露在“handle比其所指对象更长寿”的风险下。

这并不意味着绝对不可以让成员函数返回handle,有时候必须返回handle,例如vectoer的operator[],operator=返回容器内部的数据,然而着只是少数的例外。一般来讲,返回整个对象的引用来提高效率(不破坏封装,且虚吊是容易控制的,你清除你的类什么时候销毁),而非对象内部成分的引用。

记住:

避免返回handles(包括reference、指针、迭代器)指向对象内部。遵守这个条款可增加封装性,帮助const成员函数的行为像个const,并将发生“虚吊号码牌”的可能性降至最低。

条款29

为“异常安全”而努力是值得的。

当异常被抛出时,”异常安全”函数有两个条件:

  • 不泄露任何资源:从堆中申请的资源应该确保被释放
  • 不允许数据败坏:函数不能对数据修改到一半而抛出异常以致数据被破坏。

解决”不泄露任何资源”很容易,只要使用资源管理类(如shared_ptr,见条款13)即可,”不允许数据败坏”(如delete掉数据,但程序异常没有在new回来,或是数据记录先修改了,但是由于异常,实际上并不需要修改)是主要考虑的问题。在解决问题前,需要知道三个层次的保证。

异常安全函数提供以下三种层次的保证:

  • 基本保证:如果异常被抛出,程序内任何事物仍然保持在有效状态下,没有任何对象或数据结构会因此而败坏,所有对象都处于一种内部前后一致的状态(例如所有class约束条件都继续得到满足)。然而程序的现实状态很难预料,可处于任何合法状态:客户必须确认对象的状态。
  • 强烈保证:如果异常被抛出,程序状态不改变。”如果函数成功,就是完全成功;如果函数失败,程序会回复到’调用函数之前’的状态”。
  • 不抛掷(nothrow)保证:程序绝不抛出异常且总是能够完成承诺的内容。

以上三种层次的保证逐渐增强。

一般来说,实现不抛掷保证不太现实:

  • 如果程序要保证nothrow,那么就要保证它所调用的所有函数也nothrow;
  • 任何使用动态内存的代码(如STL容器)都有可能抛出无法找到足够内存而产生的ban_alloc异常。因此,提供异常安全保证通常从基本保证和强烈保证中选择。

要实现异常安全的两个条件,一般有以下策略:

  • 资源管理对象的使用以确保不泄露堆中资源;
  • 对函数语句顺序的细致规划以阻止数据的败坏。一般来讲:不要为了表示某件事将要发生而改变对象状态,除非真的发生了(例如对计数累加在对象生成之后,而非生成之前)。
  • 使用”copy and swap”策略:为打算修改的对象做出一份副本,然后在副本上进行修改,若函数抛出异常,只有副本的数据发生败坏;若修改成功执行,调用swap函数(保证不抛异常)进行置换。这是解决数据败坏的有效途径。

copy-and-swap有以下限制:

  • 使用”copy and swap”策略构造临时对象,因此要付出额外的资源和效率负担。
  • 要使用swap函数,必须保证swap函数不抛出任何异常(见条款25)。
  • 使用”copy and swap”策略不保证整个函数有强烈的异常安全性,如果函数内调用其它函数,会产生”连带影响”。比如调用两个函数,都保证强烈的异常安全,第一个成功,第二个异常。整个函数还是由于第一个函数调用成功而改变了,虽然是异常安全的,但不是强烈的。

异常安全符合短板原理:一个软件系统内只要有一个函数不符合异常安全性,整个软件系统就不具备异常安全性,没有所谓的局部安全性。

记住:

  • 异常安全函数即使发生异常也不会泄漏资源或允许任何数据结构败坏。这样的函数区分为三种可能的保证:基本型、强烈型、不抛异常型。
  • “强烈保证”往往能够以copy-and-swap实现出来,但“强烈保证”并非对所有函数都可实现或具备实现意义。
  • 函数提供的“异常安全保证”通常最高只等于其所调用之各个函数的“异常安全保证”中的最弱者。

条款30

透彻了解inlining的里里外外。

inline函数是特殊的函数,它有宏的优点,却克服了宏的缺点(条款2)。inline函数可以免除函数调用所招致的额外开销,但你实际获得的好处可能比你想象的还多,编译器会对inline函数本体执行语境相关最优化。

inline 函数背后的机制是:将对此函数的每一个调用都用函数本体替换之。这样无疑会增加产出码的大小。在内存比较小的机器上,不宜过多使用inline函数。即使使用虚拟内存,也会导致额外的换页行为(paging),降低指令高速缓存装置的击中率(instruction cache hit rate),以及伴随而来的效率损失。如果inline函数本体很小,编译器对函数本体产出的码可能比函数调用所产出的码更小;如果这样,那么将函数inlining确实会减小目标码和提高高速指令高速缓存装置的击中率。

在函数前面加上inline关键字不是强制这个函数变为inline函数,这只是向编译器提一个申请。这个申请有时是隐喻的,例如将函数定义在class内。如果把friend函数定义在class内,那么它们也将隐喻声明为inline。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//SetNumber,GetNumber 函数为隐式 inline,Try 函数为显式 inline。
class Inline {
public:
Inline(int mNumber): Number(mNumber) {}
public:
void SetNumber(int mNumber) {
Number = mNumber;
}

int GetNumber() const {
return Number;
}

private:
int Number;
};

inline void Try() {
const Inline Inline(999);
std::cout << Inline.GetNumber() << "\n";
}

明确声明为inline函数的的做法是在其定义式前加上关键字inline。例如标准的max template(来自)是这样的:

1
2
3
template<typename T>
inline const T& std::max(const T& a, const T& b)
{ return a < b ? b : a; }

inline函数和templates通常都被定义于头文件。这不是巧合。

  • inline函数通常一定被置于头文件内,大多数建置环境(build environment)在编译过程中进行inlining,将一个“函数调用”替换为“被调用函数的本体”,在替换时需要知道这个函数长什么样子(因此必须要提前定义,所以放头文件里)。有的环境可以在运行期间完成inlining,但一般是例外。Inlining在大多数C++中是编译期行为。
  • Templates通常也放置在头文件,因为它一旦被使用,编译器为了将它具体化,也需要知道它长什么样子(因此必须要提前定义,所以放头文件里)。有些编译环境可以在链接期间才执行template具体化,但是编译期间完成的更常见。

实际上templates和inlining无关,对于template,如果inline则所有的template的具体实现的函数都会被inline。如果没有理由要求都inline,则应该避免将这个template inline,否则会招来代码膨胀的成本。


大部分编译器拒绝太过复杂的inlining函数(例如有循环或递归)。virtual函数也不能是inline函数,因为virtual函数是直到运行时才确定调用哪个函数,而inline是执行前将调用动作替换为函数本体。

所以表面上是inline函数,实际上未必是,很大程度上取决于编译器。大多数编译器提供了一个诊断级别:如果无法将你要求的函数inline化,会给你一个警告信息。


编译器将inline函数调用替换为inline函数本体的同时,还是可能会为该函数生成一个函数本体。如果程序要取某个inline函数的地址,编译器通常必须为此函数生成赢outlined函数本体。如果inline函数本体不存在,自然就不会有这个函数的地址。但是,通过函数指针调用inline函数,这时inline函数一般不会被inlined。

1
2
3
4
inline void f(){……}//假设编译器会inline函数f
void ( *pf)()=f;//指向函数的指针
f();//这个调用会被inlined,因为是正常调用
pf();//这个调用可能不会被inlined,因为它是通过函数指针达成

就算你未使用函数指针,程序有时也会使用。例如,编译器会生成构造函数和析构函数的outline副本,这样就可以获得这些函数的指针,在array内部元素的构造和析构过程中使用。

实际上,把构造函数和析构函数做为inline函数未必合适,表面上看并不可以看出原因。考虑下面Derived class构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Base{
public:
……
private:
std::string bm1, bm2;
};

class Derived: public Base {
public:
Derived(){}//Derived构造函数
……
private:
std::string dm1, dm2, dm3;
};

class Derived的构造函数不含任何代码,应该是inlining函数的绝佳候选。但实际上未必。C++对于“对象被创建和销毁时都发了什么事”做了各种保证。例如,当你创建一个对象,base class和derived class的每一个成员变量都会被自动构造;当你销毁一个对象,会有反向的析构过程。

这是正常运行时的情况,但是如果对象在构造期间有异常被抛出,那么该对象已经构造好的那一部分应该自动销毁。当然,这是编译器负责的事情,但是编译器是怎么实现的呢?那就是编译器在你的程序中插入了某些代码,通常就在构造函数和析构函数内。我们可以想象一下,那个空的构造函数到底应该是怎么样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//编译后实际上可能的(或大致上的)构造函数
Derived::Derived()//概念实现
{
Base::Base();
try{ dm1.std::string::string();}//构造dm1
catch(……){
Base::~Base();//销毁base class部分
throw;//抛出异常
}

try{ dm2.std::string::string(); }//构造dm2
catch(……){
dm1.str::string::~string();//销毁dm1
Base::~Base();
throw;
}

try{ dm3.std::string::string();}
catch(……){
dm2.std::string::~string();
dm1.std::string::~string();
Base::~Base();
}
}

上面代码并不是编译器生成的,编译器生成的应该会更加精致复杂,处理异常也没这么简单。但这也足够反应空白的Derived构造函数提供的行为。不论编译器怎么优化,Derived构造函数都会调用base class的构造函数和其成员变量的构造函数,而那些调用(它们自身也可能被inlined)会影响编译器是否对此空白函数inlining。这个道理同样适用于Base构造函数,也同样适用于析构函数。

上面的原因叙述的是:也许一个空的构造函数、析构函数并不含什么代码、未做什么事情,但实际上编译后会有许多代码。然而还有另一个原因,如果其他函数经常调用这个类,那么那个函数每构建一个对象,就inline一份构造函数。例如:如果string构造函数恰巧被inlined了,那么Derived的构造函数也将获得五份“string构造函数代码”副本,这可能是巨大的代码膨胀。

因此,必须慎重考虑构造函数、析构函数是否该被inline。


程序员还要考虑将函数声明为inline带了的其他影响:inline函数无法随着程序库的升级而升级。例如,fun是个inline函数,客户将fun编进其程序中,一旦程序库设计者升级程序库,所有用到函数fun的客户端程序多必须重新编译。但是如果fun是non-inline的,客户端只需重新连接即可;如果是动态链接库,客户端甚至感觉不到程序库升级。还有一个影响就是调试。大部分调试器无法调试inline函数,因为你不能再一个不存在的函数内设立断点。

这就使得我们在使用inline时更加慎重,一开始先不要将任何函数声明为inline,后面再手工优化代码。有一个80-20经验法则:平均而言,一个程序往往将80%的执行时间花费在20%的代码上。因此,作为一个开发者,你的目标是找出这可以有效增进程序整体效率的20%代码,用inline或其他方法将它瘦身。但除非选对目标,否则一切都是虚功。

记住:

  • 将大多数inlining限制在小型、被频繁调用的函数身上。这可使日后的调试过程和二进制升级更容易,也可使潜在的代码膨胀问题最小化,使程序的速度提升机会最大化。
  • 不要只因为function templates出现在头文件,就将它们声明为inline。

条款31

将文件间的编译依存关系降至最低。

假如你修改了C++ class实现文件,修改的仅仅是实现,而没有修改接口,而且只修改private部分。此时,重新构建这个程序时,会发现整个文件、以及用到该class 的文件都被会被重新编译和连接,这不是我们想要看到的。

问题出在C++没有把关于接口与实现相分离这件事做好。C++ 的class 的定义式中不仅定义了接口,还定义了实现细目(成员变量)。例如:

1
2
3
4
5
6
7
8
9
10
11
12
class Person{ 
public:
Person(const std::string& name, const Date& birthday, const Address& addr);
std::string name() const;
std::string birthDate() const;
std::string address() const;
...
private:
std::string theName; //实现细目
Date theBirthDate; //实现细目
Address theAddress; //实现细目
};

当编译器没有取得实现代码所需要的class string,Date和Address的定义式时,它无法通过编译。

它所需要的这样的定义式往往由#include <>提供(里面有class string,Date和Address的实现代码)。例如本例中需要:

1
2
3
#include <string> 
#include "date.h"
#include "address.h"

如果这些头文件中(或头文件所依赖的头文件)的一个的实现被改变了,那么每一个含入或用到Person class的文件都得重新编译。这样的连串编译依存关系会对许多项目造成难以形容的灾难。


C++ 为什么坚持将实现细目置于class定义式中而不如下述这样做,以实现接口与实现分离呢:

1
2
3
4
5
6
7
8
9
10
11
12
namespace std { class string;} // 前置声明(不正确) 
class Date;// 前置声明
class Address;// 前置声明

class Person{
public:
Person(const std::string& name, const Date& birthday, const Address& addr);
std::string name() const;
std::string birthDate() const;
std::string address() const;
...
};

上述设想不成立,原因有两条:

  • 第一:string并不是一个class,他是一个typedef(定义为basic_string)。因此上述针对string做的声明并不正确;正确的声明比较复杂,因为涉及额外的template。退一步讲,你本来就不应该尝试手工声明标准库程序的一部分,你应该仅仅使用适当的#include完成目的。其实标准头文件这也不是编译的瓶颈,也有解决的方法。例如:你可以值改变你的接口设计,避免使用标准头文件的非法的#include。

  • 第二:问题的关键是:编译器必须在编译期间知道对象的大小。例如:下述程序中,当编译器看到x时,由于知道它是int类型的,也就知道需要为它分配多大的空间。但是当编译器看到自定义的类Person对象p时,编译器必须看到Person的类定义才能知道为p对象分配多大的内存。如果class中没有实现细目,也就是连一个成员变量都没有,那么编译器就无法确定为其分配多大内存(接口即函数是不占内存的)。

    1
    2
    3
    4
    5
    6
    int main()
    {
    int x;
    Person p(params);
    ...
    }

    使用指针,可以解决这个问题,因为一个指针的大小是确定的,如果在Person类使用指针指向成员对象,内存就可以确定下来了。

    方法一Handle classes:基本的思想就是:将对象的实现细目隐藏到一个指针(通常是一个智能指针)背后。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    #include <string>  //为了使用string
    #include <memory>
    class PersonImpl;
    class Date;
    class Address;

    class Person{
    public:
    Person(const std::string& name, const Date& birthday, const Address& addr);
    std::string name()const;
    std::string birthDate() const;
    std::string address()const;
    ...
    private:
    std::tr1::shared_ptr<PersonImpl> pImpl; // 指向实现物的指针
    };

    上述程序中,将原本的Person 类写成两个部分,接口那部分是主要的部分,其中含了一个智能指针,指向实现细目。而实现细目另外定义了一个类:PersonImpl。这种设计手法被称为:pimpl idiom。

    注意:pimpl 指的是 pointer to implementation。这种class内的指针往往被称为:pImpl指针。上述class的写法 往往被称为handle class。

    这样做使得接口与实现分离。即:Person的客户与 [Date、Address、以及Person的实现细目]就分离了。这带来的好处是:

    • 这些class的修改,都不需要Person客户进行重新编译(它们不会导致Person改变,指针大小是确定的)。
    • 而且由于客户无法看到实现细目,也就不能写出由这些实现细目所决定的代码。

    这个分离的关键在于以“声明的依存性”替换“定义的依存性”,这正是编译器依存性最小化的本质:现实中让头文件尽可能自我满足,万一做不到,则让它与其他文件内的声明式(而非定义式)相依。

    其他情况也来自于这个设计策略:

    • 如果用 object reference 或 object pointer 可以完成任务,就不要用 objects。可以只靠声明式定义出指向该类型的 pointer 和 reference;但如果定义某类型的 objects,就需要用到该类型的定义式(要分配内存)。
    • 如果能够,尽量以 class 声明式替换 class 定义式。当你声明一个函数而它用到某个 class 时,你并不需要该 class 的定义式,纵使函数以 by value 方式传递该类型的参数(或返回值)亦然。
      • 真正的定义式在函数调用前曝光即可。因为用户不太可能会用到所有的函数,客户只需要引入需要的函数的class定义式的头文件即可。
      • 此外这里没有谈到函数的定义,因为一般头文件进行函数的声明,而定义在cpp文件。声明是在编译时处理的而定义在链接时处理,这里讨论的问题不涉及链接。
        • 补充:正常情况下,应该是:函数声明在.h中,函数定义在.cpp中。 原因: (1)如果你对这个函数体进行了修改,那么只会重新编译相应的.cpp文件,在进行大工程编译时,会大大缩短编译时间。 (2)即便是对整个工程进行重新编译,在进行代码扩充阶段时,定义在.h中的情况,要比定义在.cpp的情况浪费更多的内存和编译时间。 2. 纯粹从理论角度来说,函数可以定义在.h中,但一定要加上#ifdef – #define – #endif对代码块进行包裹,避免出现重复定义的链接错误。
    • 为声明式和定义式提供不同的头文件。两个头文件应该保持一致性,其中一个头文件发生改变,另一个就得也改变。一个内含了class 接口的定义,另一个仅仅内含声明。比如客户要使用Date,那么程序库作者应该提供声明的头文件和定义的头文件,客户引入声明的头文件来声明函数而不需要自己前置声明Date类。头文件名称如<datefwd.h>,这个命名方式取法C++标准程序库头文件。
      • 因而,这个条款也使用于template,因为template虽然一般定义式也写在头文件,但有的定义式也在非头文件内,这样就可以把只含声明式的头文件提供给template。

方法二:Interface classes手法,基本思想是:令Person class 成为一种特殊的abstract base class (抽象基类),称为interface class。

这样的类通常:没有成员变量,也没有构造函数,只有一个virtual 的析构函数以及一组pure virtual 用来描述接口。对于 Interface class 的客户,必须以接口的指针或者引用来编写应用程序。因为不可能针对内含 pure-virtual 的函数的 abstract class 具现出实例。就像 Handle class的客户那样,除非 Interface class 的接口被修改,否则客户不需要重新编译。

由于这样的类往往没有构造函数,因此通过工厂函数或者virtual构造函数创建,他们返回指针,指向动态分配对象所得的对象,这样的对象支持interface class的接口,这样的函数在interface class往往被声明为 static,例如

1
2
3
4
5
6
class Person{ 
public:
...
static std::tr1::shared_ptr<Person>
create(const std::string& name, const Date& birthday, const Address& addr);
};

客户使用他们像这样:

1
2
3
4
5
6
7
8
9
10
11
std::string name; 
Date dateBirth;
Address address;
std::tr1::shared_ptr<Person> pp(Person::create(name, dateBirth, address));
...
std::cout << pp->name()
<< "was born on "
<< PP->birthDate()
<< " and now lives at "
<< pp->address();
...

当然支持 interface class 接口的那个具象类(concrete classes)必须被定义出来,而真正的构造函数必须被调用。假设有个 derived class RealPerson,提供继承而来的 virtual 函数的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class RealPerson : public Person{ 
public:
RealPerson(const std::string& name, const Date& birthday, const Address& addr)
: theName(name), theBirthDate(birthday), theAddress(addr)
{}
virtual ~RealPerson(){}

std::string name() const;
std::string birthDate() const;
std::string address() const;

private:
std::string theName;
Date theBirthDate;
Address theAddress;
};

有了 RealPerson 之后,写出 Person::create 就真的一点也不稀奇了:

1
2
3
4
std::tr1::shared_ptr<Person> Person::create(const std::string& name, const Date& birthday, const Address& addr) 
{
return std::tr1::shared_ptr<Person>(new RealPerson(name, birthday, addr));
}

一个更现实的 Person::create 实现代码会创建不同类型的 derived class 对象,取决于诸如额外参数值、独自文件或数据库的数据、环境变量等等。

RealPerson 示范实现了 Interface class 的两个最常见机制之一:从 interface class 继承接口规格,然后实现出接口所覆盖的函数。


handle classes 和 interface classes 解除了接口和实现之间的耦合关系,从而降低文件间的编译依存性。但也会带来开销:

  • handle classe:

    成员函数必须通过 implementation pointer 取得对象数据。那会为每一次访问增加一层间接性。每个对象消耗的内存必须增加一个 implementation pointer 的大小。 implementation pointer 必须初始化指向一个动态分配的 implementation object,所以还得蒙受因动态内存分配儿带来的额外开销。

  • Interface classe:

    由于每个函数都是 virtual,必须为每次函数调用付出一个间接跳跃。此外 Interface class 派生的对象必须内含一个 vptr(virtual table pointer)。

在程序开发过程中使用 handle class 和 interface class 以求实现码有所改变时对其客户带来最小冲击。

而当他们导致速度和/或大小差异过于重大以至于 class 之间的耦合相形之下不成为关键时,就以具象类(concrete class)替换 handle class 和 interface class。

记住:

  • 支持“编译依存性最小化”的一般构想是:相依于声明式,而不要相依于定义式。基于此构想的两个手段是Handle classes和Interface classes。
  • 程序库头文件应该以“完全且仅有声明式”的形式存在。这种做法不论是否涉及templates都适用。

条款32

确定你的public继承塑膜出is-a关系。

以C++进行面向对象编程,最重要的一个规则是:public inheritance意味着”is-a”的关系。

如果令 class D(”Derived”)以 public 形式继承 class B(”Base”),便是告诉C++编译器说,每一个类型为D的对象同时也是一个类型为B的对象。意思是B比D表现出更一般化的概念,而D比B表现出更特殊化的概念。

以一个具体例子来说明:

1
2
class Person {……};
class Student: public Person {……};

由生活经验可以知道,每个学生都是人,但是并非每个人都是学生。这就是这个继承体系的主张。可以预期,对人成立的每件事,对学生也都成立。但是对学生成立的事对人未必成立。在C++中,任何函数如果期望接受类型为Person对象的实参(或pointer to person,或reference to person),也都可以接受一个类型为Student对象的实参(或pointer to student,或reference to student)。

1
2
3
4
5
6
7
8
void eat(const Person& p);
void study(const Student& s);
Person p;
Student s;
eat(p);//正确
eat(s);//正确,s是人
study(s);//正确
study(p);//错误 ,p不是学生

当然,上面正确的前提是以public继承,如果以private继承,意义将完全不同(条款39),protected继承也是一样。


有时public和is-a之间的关系会误导我们。例如,企鹅(penguin)是一种鸟,这是事实;鸟可以飞,这也是事实。如果以C++描述这层关系:

1
2
3
4
5
6
7
8
class Bird{
public
virtual void fly();
……
};
class Penguin: public Bird{
……
};

但是我们知道,企鹅不会飞,这个是事实。这个问题的原因是语言(英语)不严谨。当我们说鸟会飞时,我们表达的意思是一般的鸟都会飞,并不是表达所有的鸟都会飞。我们还应该承认一个事实:有些鸟不会飞。这样可以塑造一下继承关系:

1
2
3
4
5
6
7
8
9
10
11
class Bird{
……
};
class FlyingBird: public Bird{
public:
vitual void fly();
……
};
class Penguin: public Bird{//没有fly函数
……
};

这样的设计能更好的反映我们真正要表达的意思。但是这时,我们仍未完全处理好这些鸟事。例如,如果你的系统不会区分鸟会不会飞,你关心的是鸟啄和鸟翅,这样的话,原先的“双class继承体系”更适合你的系统。并不存在完美设计,具体问题要具体讨论。

还有一个方法来处理“所有鸟都会飞,企鹅是鸟,但企鹅不会飞”这个问题,我们可以在企鹅类重新定义fly函数,让它在产生一个运行期错误:

1
2
3
4
5
6
void error(const std::string& msg);//输出错误
class Penguin: public Bird {
public:
virtual void fly(){ error("Attemp to make a penguin fly");}
……
};

这里前面的解决方法不同,这里不说企鹅不会飞,当你说企鹅会飞时,会告诉你这是一个错误。但是这种解决方法之间有什么差异?从错误被侦测出来的时间来看:

  • 第一种解决方法“企鹅不会飞”这个限制条件在编译期强加事实;
  • 第二个解决方法,“企鹅会飞是错误”是在运行期检测出来的。
  • 第一种解决方法更好,条款18说过,好的接口可以防止无效的代码通过编译,相比之下,我们应该选择在编译期来找出这个问题。

在考虑一个例子,基础几何我们都学过,那么正方形和矩形的关系有多么复杂呢?先看下面这个例子:class Square应该以public形式基础class Rectangle吗?我们都知道正方形是特殊的矩形,如果以public继承

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Rectangle{
public:
virtual void setHeight(int newHeight);
virtual void setWidth(int newWidth);
virtual int height() const;
virtual int width() const;
……
};

void makeBigger(Rectangle& r)//增加r的面积
{
int oldHeight=r.height;
r.setWidth(r.width()+10);//r宽度增加
assert(r.height()==oldHeight);//判断r的高度是否改变
}

上面的assert结果肯定为真,因为makeBigger只是改变了r的宽度,高度并未改变。

1
2
3
4
5
6
class Square:public Rectangle{……};
Square s;
……
asseret(s.width()==s.height());//对所有正方形都为真
makeBigger(s);//因为是public继承是is-a关系,所以可以使用这个函数
asseret(s.width()==s.height());//对正方形也应该为真

那么现在肯定是有问题了。因为第一个assert时,长和宽多相等;之后增加了宽度,长度不变;到了第二个assert时,长和宽还相等。

前面说过,以public继承,能够施行于base class对象身上的每件事,都可以施行于derived对象身上。在正方形和矩形的例子(还有一个类似的是条款38的sets和lists),这个结论行不通,所以——用public继承塑模它们之间的关系不正确。我们应该记住:代码通过编译不表示就可以正确运行。

is-a只是存在class继承关系中的一种,还有两个继承关系式has-a(有一个)和is-implemented-in-terms-of(根据某物实现出)。这些关系将在条款38和条款39讨论。在设计类时,应该了解这些classes之间的相互关系和相互差异,在去塑模类之间的关系。

记住:

“public 继承”意味着 is-a。适用于base classes身上的每一件事情一定也适用于derived classes身上,因为每一个derived class对象也都是一个base class对象。

条款33

避免遮掩继承而来的名称。

这里说的名称,是和继承以及作用域有关。先看一个和作用域有关的例子:

1
2
3
4
5
6
int x;//global
void someFunc();
{
double x;//local
std::cin>>x;//给local变量赋值
}

这个cin是给local变量x赋值,而不是global变量x,因为内层作用域名称会遮掩外围作用域名称。当编译器在someFunc作用域内遇到名称x时,它在local作用域内查找是否有这个变量定义,如果找不到就再去找其他作用域。这个例子中的变量x类型不同,local的是double类型,而global的是int类型;但是这个并不要紧,C++的名称遮掩规则(name-hiding rules)所做的唯一事情就是:遮掩名称,至于类型并不重要。


现在来看一下继承。当一个derived class成员函数内指涉(refer to) base class内的某物(成员函数、成员变量、typedef等)时,编译器可以找到所指涉的东西,因为derived class继承了声明在base class内的所有东西。derived class的作用域被嵌套在base class作用域内。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Base{
private:
int x;
public:
virtual void mf1()=0;
virtual void mf2();
void mf3();
……
};
class Derived: public Base{
public:
virtual void mf1();
void mf4();
……
};

这个例子中既有public,又有private。成员函数有pure virtual、impure virtual和non-virtual,这是为了强调我们讨论的是名称,和其他无关。这个例子是单一继承,了解单一继承很容易推断多重继承。假设在derived class的mf4内调用mf2

1
2
3
4
void Derived::mf4()
{
mf2();
}

当编译器看到mf2时,要知道它指涉(refer to)什么东西。首先在local作用域内(即mf4覆盖的作用域)查找有没有名称为mf2的东西;如果找不到,再查找外围作用域(class Derived覆盖的作用域);如果还没找到,再往外围找(base class覆盖作用域),在这里找到了。如果base内还是没找到,之后继续在base那个namespace作用域内找,最后往global作用域找。

下面把这个例子变得稍微复杂一点,重载mf1和mf3,且添加一个新版mf3到Derived中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Base{
private:
int x;
public:
virtual void mf1()=0;
virtual void mf1(int);
virtual void mf2();
void mf3();
void mf3(double);
……
};
class Derived: public Base{
public:
virtual void mf1();
void mf3();
void mf4();
……
};

因为以作用域为基础的“名称遮掩规则”,base class内所有名称为mf1和mf3的函数都被derived class内的mf1和mf3函数遮掩掉了。从名称查找观点来看,Base::mf1和Base::mf3都不再被Derived继承。

1
2
3
4
5
6
7
Derived d;
int x;
d.mf1();//正确,调用Derived::mf1
d.mf1(x);//错误,因为Derived::mf1遮掩了Base::mf1
d.mf2();//正确,调用Base::mf2
d.mf3();//正确,调用Derived::mf3
d.mf3(x);//错误,因为Derived::mf3遮掩了Base::mf3

条款32中说过public继承是”is-a”关系,如果使用public继承而又不继承那些重载函数,就是违反了”is-a”关系。要想上面的函数调用都正确,可是使用using声明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
 class Base{
private:
int x;
public:
virtual void mf1()=0;
virtual void mf1(int);
virtual void mf2();
void mf3();
void mf3(double);
……
};
class Derived: public Base{
public:
//让Base class内名为mf1和mf3的所有东西在Derived作用域内都可见,且为public
using Base::mf1;
using Base::mf3;
virtual void mf1();
void mf3();
void mf4();
……
};
//这就相当于派生类里有许多重载版本的函数

这样,下面的调用都不会出错了。

1
2
3
4
5
6
7
Derived d;
int x;
d.mf1();//正确,调用Derived::mf1
d.mf1(x);//调用Base::mf1
d.mf2();//正确,调用Base::mf2
d.mf3();//正确,调用Derived::mf3
d.mf3(x);//调用Base::mf3

如果你继承base class,且加上重载函数;你又希望重新定义或覆写其中一部分,那么要把被遮掩的每个名称引入一个using声明。

public继承暗示base和derived class之间是一种”is-a”关系,这也是上述using声明放在derived class的public作用域内的原因:base class内的public名称在publicly derived class内也应该是public。

如果想要private继承Base,而Derived唯一想继承的是时Base内mf1无参数的那个版本,using声明在这派不上用场,因为using声明会使继承而来的某个名称所有函数在derived class都可以见。这样的实现需要一个不同的技术,一个简单的转交函数(forwarding function):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Base{
public:
virtual void mf1()=0;
virtual void mf1(int);
};
class Derived: private Base{
public:
virtual void mf1()//转交函数(forwarding function),只转交某个版本的mf1
{Base::mf1();};//隐式成为inline
};
Derived d;
int x;
d.mf1();//调用Derived::mf1
d.mf1(x);//错误,Base::mf1被遮掩了

上面所述都是不含templates。当继承结合templates时,又会面临“继承名称被遮掩”,关于以“角括号定界”的东西,在条款43讨论。

记住:

  • derived classes内的名称会遮掩base classes内的名称。在public继承下从来没有人希望如此。
  • 为了让被遮掩的名称再见天日,可使用using声明式或转交函数。

条款34

区分接口继承和实现继承。

public继承的概念,由2部分构成:函数接口(function Interface)继承和函数实现(function implementation)继承。我们在设计class时:

  • 有时希望derived class只继承函数的接口(即函数声明);
  • 有时候希望derived class继承函数接口和实现,但又覆写它们所继承的实现;
  • 又有时候希望derived class同时继承函数的接口和实现,但不覆写任何东西。

为了更好理解上述差异,用一个绘图程序来说明:

1
2
3
4
5
6
7
8
9
class Shape{
public:
virtual void draw() const=0;
virtual void error(const std::string& msg);
int objectID() const;
……
};
class Rectangle: public Shape{……};
class Ellipse:public Shape{……};

Shape中有pure virtual函数,所以它是个抽象类,不能创建Shape对象,但Shape强烈影响了所有以public继承它的derivedclass,因为成员函数的接口总会被继承。条款32所说,public继承意味着is-a。

Shape class有三个函数。draw是pure virtual函数;error是impure pure函数;objectID是non-virtual函数。

pure virtual函数有两个特点:它们必须被继承了它们的具体class重新声明,而且在抽象class中通常没有定义。这也就是说明:

  • 声明一个pure virtual函数的目的是为了让derived class只继承函数接口。

但是我们可以为pure virtual函数提供定义,即为Share::draw提供一份实现,C++不会发出怨言,但是调用这个函数的唯一途径是调用时指明其class名称:

1
2
3
Shape* ps=new Shape;
ps->draw();
ps->Share::draw();

impure virtual函数和pure virtual函数有所不同,derived classes继承其函数接口,但impure virtual函数会提供一份实现代码,derived class可能覆写(override)它。

  • 声明简朴的(非纯)impure virtual函数的目的,是让derived classes继承该函数的接口和缺省实现。

考虑Shape::error这个例子,error接口表示,每个class都必须支持一个遇上错误时可调用的函数,但每个class可以自由处理错误。如果某个class不想针对错误做出特殊行为,可以退回到Shape class提供的缺省错误处理行为。也就是说Shape::error的声明式告诉derived class设计者:你必须支持一个error函数,但如果你不想自己写,可以使用Shape class提供的缺省版本。

如果允许impure virtual函数同时指定函数声明和函数缺省行为,有可能造成危险。考虑一个具体例子,一个XYZ航空公司设计飞机继承体系,该公司有A型和B型两种飞机,都以相同方式飞行,可以考虑这样设计继承体系:

1
2
3
4
5
6
7
8
9
10
11
12
class Airport{ ……};
class Airplane{
public:
virtual void fly(const Airport& destation);
……
};
void Airplane::fly(const Airport& destation)
{
//将飞机飞到指定的destination
}
class ModelA: public Airplane{……};
class ModelB: public Airplane{……};

因为不同型飞机不需要不同的fly实现,Airplane::fly被声明为virtual;为了避免在ModelA和ModelB重新撰写相同代码,缺省的飞行行为有Airplane::fly提供。

上面这种设计方式是典型的面向对象设计。两个classes共享的性质放到base class中,然后被这两个class继承。这样可以突出共同性质,避免代码重复。

但是如果XYZ要购买一种新型飞机C,C和A、B飞行方式不同。XYZ公司程序员给C型飞机添加了一个class,但是没有重新定义fly函数,然后又写了如下代码

1
2
3
4
Airport PDX()//某个机场
Airplane* pa=new ModelC;
……
pa->fly(PDX);//调用了Airplane::fly

这会造成大灾难,因为程序员试图以ModelA或ModelB的方式来飞ModelC。问题不在于Airplane::fly有缺省行为,在于ModelC在未搞清楚的情况下就使用了这个缺省行为。幸运的是可以做到:提供缺省实现给derived classes,除非derived classes真的要用。这个做法是切断virtual函数接口和其缺省实现之间的连接。

1
2
3
4
5
6
7
8
9
10
class Airplane{
public:
virtual void fly(const Airport& destation)=0;
protected:
void defaultFly(const Airport& destation);
};
void Airplane::deFaultFly(const Airport& destation)
{
//将飞机飞到指定目的地
}

这里将Airplane::fly改为pure virtual函数,只提供接口。但是缺省的行为在Airplane::defaultFly函数中出现。如果要使用其缺省行为,可以在fly函数调用defaultFly函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 也就是告诉派生类必须实现fly,但你可以用我提供的默认飞行模式   
class ModelA: public Airplane{
public:
virtual void fly(const Airport& destation)
{ defaultFly(destation)}
};
class ModelB: public Airplane
……

class ModelC: public Airplane{
public:
virtual void fly(const Airport& destination);
};
void ModelC:fly(const Airport& destination)
{
//将C型飞机飞到指定目的地
}

上面设计中,Airplane::defaultFly是个non-virtual,derived classes不用重新定义(条款36)。如果Airplane::defaultFly是virtual函数,就会出现循环问题:万一derived classes忘记重新定义defaultFly函数会怎样?

有的人返回以不同的函数分别将提供接口和缺省实现,这样会因为过度雷同的函数名称引起class命名空间污染问题;但是他们同意接口和缺省实现应该分开。我们可以利用“pure virtual函数必须在derived classes中重新声明,但它们可以拥有自己的实现”这个特点

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
   // 依旧是转调用默认版本,不同的是,默认版本由纯虚函数的实现版本提供,这样不会显得命名空间臃肿
// 同时,纯虚函数也强制派生类根据自己实现一份版本
class Airplane{
public:
virtual void fly(const Airport& destination)=0;
……
};
void Airplane::fly(const Airport& destination)//pure virtual函数实现
{
//缺省实现
}


class ModelA: public Airplane{
public:
virtual void fly(const Airport& destination)
{Airplane::fly(destination);}
……
};
class ModelB:public Airplane
……

class ModelC: public Airplane
{
public:
virtual void fly(const Airport& destination);
……
};
void ModelC::fly(const Airport& destination)
{
//ModelC的实现
}

这个实现和上一个不同之处在于,用pure virtual函数Airplane::fly替换了独立函数Airplane::defaultFly。现在的fly被分割为两个基本要素:其声明部分表现为接口(derived classes必须使用),定义部分表现为缺省行为(derived classes明确提出申请才可以用)。


最后来看看Shape的non-virtual函数objectID;Shape::objectID是个non-virtual函数,这意味着它不打算在derived class中有不同行为。

  • 声明non-virtual函数的目的是为了令derived classes继承函数 的接口及一份强制性实现。

可以把Shape::objectID看做“每个Shape对象都有一个用来产生识别码的函数,这个识别码采用相同计算方法。non-virtual函数代表的意义是不变性(invariant)凌驾特异性(specialization),所以不应该在derived classes中被重新定义。


pure virtual函数对应只继承接口;simple(impure) virtual函数对应继承接口和一份缺省实现;non-virtual函数对应继承接口和一份强制实现。在设计classes时,要分清这些区别和联系,否则容易犯两个错误:

  • 第一个错误是将所有函数声明为non-virtual。这会使derived classes没有空间进行特化工作;non-virtual析构函数会有问题(条款7)。如果关心virtual函数的成本问题,可以参考条款30的80-20法则。典型的程序有80%时间在执行20%代码,函数中有80%的virtual函数不一定会给程序带了多大效率损失,将心力花在那些20%代码上才是关键。
  • 第二个错误是将所有成员函数声明为virtual。有时候这样是正确的,例如条款31的Interface classes。但如果有些函数在derived classes中确实不应该被重新定义,那么就应该将这些函数声明为non-virtual。

记住:

  • 接口继承和实现继承不同。在public继承之下,derived classes总是继承base class的接口。
  • pure virtual函数只具体指定接口继承。(附:有强烈的特异性,必须重新实现。并且纯虚函数的定义实现可以用来充当缺省版本)
  • 简朴的(非纯)impure virtual函数具体指定接口继承及缺省实现继承。(附:根据自己的特殊情况看需不需要覆写,如果没必要可以用基类的版本,但这个缺省的功能可能会产生一些危险。)
  • non-virtual函数具体指定接口继承以及强制性实现继承。(附:强烈的共性,派生类只管继承不覆写)

条款35

考虑virtual函数以外的其他选择。

假设你在制作一款游戏,游戏内的人物都有自己的生命值,而不同的人物会有不同的方式来计算它们健康指数,所以,需要将成员函数healthValue() 声明为虚函数是非常正确的:

1
2
3
4
5
class GameCharacter  {
public:
virtual int healthValue() const;
...
};

healthValue() 并未被声明为纯虚函数,这表示有一个计算健康指数的默认算法。但是,这样做并不是最完美,它也有缺陷,有没有替代方式呢?


方法1:藉由Non-Virtual Interface手法实现Template Method模式

此手法主张的做法:

  • 将healthValue()函数声明为public,并且改为non-virtual函数
  • 再设计一个private virtual函数,将healthValue()原本的功能移至该函数中,然后在healthValue()函数中调用该函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class GameCharacter {
public:
//派生类不应该重新定义它
int healthValue()const {
//... //事前工作
int retVal = doHealthValue();
//.. //事后工作
return retVal;
}
private:
//返回人物的健康指数,派生类可以重新定义它
virtual int doHealthValue()const {
}
};

NVI手法特点:

  • 令客户通过public non-virtual成员函数间接调用private virtual函数,称为non-virtual interface(NVI)手法。它是所谓Template Method设计模式的一个独特表现形式。我们把这个non-virtual 函数称为virtual函数的外覆器
  • NVI手法的优点:我们可以在non-virtual函数中做一些其他事情。例如:
    • 事前工作:可以进行锁定互斥器、制造运转日志记录项、验证class约束条件、验证函数先决条件等等
    • 事后工作:可以进行互斥器解锁、验证函数的事后条件、再次验证class约束条件等等

这些优点是在客户端直接调用virtual函数的情况中做不到的

private的虚函数并不会改变多态性,只是改变了访问权限而已。NVI手法可以在派生类中重新定义private virtual函数:

  • 重新定义virtual函数:表示某些事“如何”被完成
  • 调用virtual函数:表示它何时被完成
  • 这两件事情互不干扰。因此NVI手法允许在派生类中重新定义virtual函数,从而赋予它们“如何实现机能”的控制能力
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
class GameCharacter {
//同上
private:
virtual int doHealthValue()const {
std::cout << "Base" << std::endl;
return 0; //返回值是为了代码编译通过,无特殊意义
}
};

class Hero :public GameCharacter {
private:
//重写基类的doHealthValue()
virtual int doHealthValue()const {
std::cout << "Derived" << std::endl;
return 0; //同上
}
};

int main()
{
GameCharacter *p = new GameCharacter;
p->healthValue(); //打印:Base

GameCharacter *p2 = new Hero;
p2->healthValue(); //打印:Derived
return 0;
}

关于virtual函数的访问级别:

  • 在NVI手法下其实virtual函数不一定得是private的
  • 在有些情况下,要求派生类在virtual函数中调用基类的virtual函数(参阅条款27),那么当virtual函数在基类中为private之后,派生类就不可以访问了。因此,在这种情况下,virtual函数可以设置为protected。有时甚至一定得是public。
  • 在NVI手法下,virtual函数尽量不要设置为public,因为设置为public之后,就与NVI手法的初衷相反了,失去了封装性

方法2:藉由Function Pointers实现Strategy模式

上面介绍的NVI方法虽然可以避免客户端直接调用virtual函数,但是在non-virtual函数中还是调用了virtual函数,这种方法还是没有免去定义virtual函数的情况。现在我们进行另一种设计,要求每个人物的构造函数接受一个指针,指向一个健康计算函数,我们可以调用该函数进行实际计算。代码如下:

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

//默认的,计算健康指数
int defaultHealthCala(const GameCharacter& gc);

class GameCharacter {
public:
//函数指针别名
typedef int(*HealthCalcFunc)(const GameCharacter& gc);

//构造函数
explicit GameCharacter(HealthCalaFunc hcf = defaultHealthCalc)
:healthFunc(hcf) {}

int healthValue() {
//通过函数指针调用函数
return healthFunc(*this);
}
private:
HealthCalcFunc healthFunc; //函数指针
};

这样做的优点是:同一个人物类型的不同实例之间可以有不同的健康计算函数。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class GameCharacter { //同上 };
class EvilBadGuy :public GameCharacter {
explicit EvilBadGuy(HealthCalaFunc hcf = defaultHealthCalc)
:GameCharacter(hcf) {}
//..
};

int loseHealthQuickly(const GameCharacter&);
int loseHealthSlowly(const GameCharacter&);

int main()
{
EvilBadGuy ebg1(loseHealthQuickly);
EvilBadGuy ebg2(loseHealthQuickly);
return 0;
}

同时,已定义的对象,在运行期间可以更改健康指数计算函数。例如,可以在类中再添加一个成员函数,用来更改当前计算健康指数的函数指针。

这种方法的争议是:

  • 当全局函数可以根据class的public接口来取得信息并且加以计算,那么这种方法是没有问题的。但是如果计算需要访问到class的non-public信息,那么全局函数就不可以使用了。
  • 解决上面的问题,唯一方法就是:弱化class的封装。例如将这个全局函数定义为class的friend,或者为其某一部分提供public访问函数
  • 因此,这些争议对于“以函数指针替换virtual函数”其是否利大于弊?取决于你的是继续需求

方法3:藉由tr1::function完成Strategy模式

使用全局函数替换成员函数,这种成员函数太过死板,因为“健康指数计算”不必非得是个函数,还可以是其他类型的东西(例如函数模板、函数对象、成员函数、仿函数等),只要其能计算“健康指数”即可,我们可以使用C++标准库中的function模板来取代全局函数。

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

int defaultHealthCala(const GameCharacter& gc);

class GameCharacter {
public:
//其余部分同上
//只是将函数指针改为了function模板,其接受一个const GameCharacter&参数,并返回int
typedef std::tr1::function<int(const GameCharacter&)> HealthCalcFunc;

explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc)
:healthFunc(hcf) {}

int healthValue() {
return healthFunc(*this);
}
private:
HealthCalcFunc healthFunc;
};

现在我们可以不单单调用全局函数来计算“人物的健康指数”,还可以设计很多种方式来计算

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
38
39
40
class GameCharacter { //同上};

class EvilBadGuy :public GameCharacter {
public:
explicit EvilBadGuy(HealthCalcFunc hcf = defaultHealthCalc)
:GameCharacter(hcf) {}
//..
};

class EyeCandyCharacter :public GameCharacter {
//构造函数类似EvilBadGuy
};

//计算健康指数函数
short calcHealth(const GameCharacter&);

//函数对象,用来计算健康指数
struct HealthCalculator {
int operator()(const GameCharacter&)const {}
};

//其提供一个成员函数,用以计算健康
class GameLevel {
public:
float health(const GameCharacter&)const;
};

int main()
{
//人物1,其使用calcHealth()函数来计算健康指数
EvilBadGuy ebg1(calcHealth);

//人物2,其使用HealthCalculator()函数对象来计算健康指数
EyeCandyCharacter ecc1(HealthCalculator());

//人物2,其使用GameLevel类的health()成员函数来计算健康指数
GameLevel currentLevel;
EvilBadGuy ebg2(std::tr1::bind(&GameLevel::health, currentLevel, _1));//传入成员函数的第一个参数*this
return 0;
}

优点是:

  • 支持隐式转换,参数可被隐式转换为const GameCharacter&,返回类型可以被隐式转换为int;
  • 支持了任何兼容的的可调用物(兼容就是指能隐式转换参数、返回值的)
  • 能用tr1::bind进行参数扩展

方法4:古典的Strategy模式

在古典的Strategy设计模式中,会将用来计算健康的函数设计为一个继承体系,并且有virtual函数,这些函数用来计算健康

  • GameCharacter是一个继承体系的根类,其派生类有EvilBadGuy、EyeCandyCharacter
  • HealthCalcFunc是一个继承体系的根类,其派生类有SlowHealthLoser、FastHealthLoser
  • 每一个GameCharacter对象都内含一个指针,指向于一个来自HealthCalcFunc继承体系中的对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class GameCharacter;
class HealthCalcFunc { //计算健康指数的类
public:
virtual int calc(const GameCharacter& gc)const {}
};

HealthCalcFunc defaultHealthCalc;

class GameCharacter {
public:
explicit GameCharacter(HealthCalcFunc* hcf = &defaultHealthCalc)
:pHealthCalc(hcf) {}

int healthValue() {
return pHealthCalc->calc(*this);
}
private:
HealthCalcFunc* pHealthCalc;
};

这个模式也具有弹性,例如为HealthCalcFunc类添加派生类,那么就可以纳入新的计算方法


四种方法总结:

  • 使用non-virtual interface(NVI)手法,那是Template Method设计模式的一种特殊形式。它以public non-virtual成员函数包裹较低访问性(private或protected)的virtual函数
  • 将virtual函数替换为“函数指针成员变量”,这是Strategy设计模式的一种分解表现形式
  • 以tr1::function成员变量替换virtual函数,因而允许使用任何可调用物搭配一个兼容于需求的签名式。这也是Strategy设计模式的某种形式
  • 将继承体系内的virtual函数替换为另一个继承体系内的virtual函数。这是Strategy设计模式的传统实现手法

记住:

  • virtual函数的替代方案包括NVI手法及Strategy设计模式的多种形式。NVI手法自身是一个特殊形式的Template Method设计模式。
  • 将机能从成员函数移到class外部函数,带来的一个缺点是,非成员函数无法访问class的non-public成员。
  • tr1::function对象的行为就像一般函数指针。这样的对象可接纳“与给定之目标签名式兼容”的所有可调用物。

条款36

绝不重新定义继承而来的non-virtual函数。

假设 class D系由 class B以 public 形式派生出来,class B定义有一个 public 成员函数mf。由于mf的参数和返回值都不重要,所以假设两者皆为 void。

1
2
3
4
5
class B {
public:
void mf();
};
class D : public B { ... };

虽然我们对于B、D和mf一无所知,但面对一个类型为D的对象 x:

1
D x;        // x是一个类型为D的对象

如果以下行为:

1
2
B *pB = &x; // 获得一个指针指向x
pB->mf(); // 经由该指针调用mf

异于以下行为:

1
2
D *pD = &x;
pD->mf();

两者都通过对象 x 调用成员函数 mf,由于两者所调用的函数都相同,所以行为应该相同,是吗?是的,一般如此。

更明确地说,如果mf是个non-virtual 函数而D定义有自己的mf,那就不是如此:

1
2
3
4
5
6
7
class D : public B {
public:
void mf(); // 遮掩了B::mf
...
};
pB->mf(); // 调用B::mf
pD->mf(); // 调用D::mf

造成此行为的原因是:non-virtual 函数如 B::mf 和 D::mf 都是静态绑定(staticlly bound,详见条款37)。这意思是说,由于pB被声明为一个pointer-to-B,通过pB调用的non-virtual 函数永远是B所定义的版本,即使pB指向一个类型为”D派生的class”的对象。

但另一方面,virtual 函数却是动态绑定(dynamically bound,详见条款37),所以它们不受这个问题困扰。如果mf是个 virtual 函数,不论是通过pB或pD调用mf,都会导致调用D::mf,因为pB和pD真正指的都是一个类型为D的对象。

如果正在编写 class D并重新定义继承自 class B的non-virtual 函数mf,D对象很可能展现出不一致的行为。更明确地说,当mf被调用,任何一个D对象都可能展现出B或D的行为:决定因素不在对象自身,而在于”指向该对象的指针”当初的声明类型。


但那只是实务面上的讨论,真正的理论层面的理由,接下来讨论:

条款32已经说过,所谓 public 继承意味is-a的关系。条款34则描述为什么在 class 内声明一个non-virtual 函数会为该 class 建立起一个不变性,凌驾其特异性。如果将这两个观点施行于两个 class B和D以及non-virtual 成员函数B::mf身上,那么:

  • 适用于B对象的每一件事,也适用于D对象,因为每个D对象都是一个B对象
  • B的derived class 一定会继承mf的接口和实现,因为mf是B的一个non-virtual函数

现在,如果D重新定义mf,设计便出现矛盾。既然D以 public 形式继承B,并且mf是B的一个non-virtual 函数,那么D的mf行为和B的mf行为必须是一致的。但D又重新定义mf,这就发生了矛盾。

因此,任何情况下都不应该重新定义一个继承而来的non-virtual 函数。

同时这个条款也解释了为什么多态性base class 内的析构函数应该是 virtual。如果违反了这个准则,在base class 内声明一个non-virtual 析构函数,那么在derived class 就不能重新定义一个派生类的non-virtual 析构函数。但即使没有重新定义non-virtual 析构函数,编译器也会为derived class 定义一个默认的析构函数,仍然发生矛盾。

记住:

绝不重新定义继承而来的non-virtual函数。

条款37

绝不重新定义继承而来的缺省参数值。

我们在条款36刚刚说过继承non-virtual函数是错误的。所以本条款更确切的说是:绝不重新定义继承而来的带有缺省参数值的virtual函数。理由很明确:virtual是动态绑定,而缺省参数是静态绑定。动态绑定又叫后期绑定,静态绑定又叫前期绑定。

先说一下静态类型和动态类型的概念:对象的所谓静态类型就是它在程序中被声明时所采用的类型 ,对象的动态类型指的是目前所指对象的类型,也就是说动态类型可以表现出一个对象将会有什么行为。

下面是一个继承体系:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Shape {
public:
enum ShapeColor { Red, Green, Blue };
virtual void draw(ShapeColor color = Red)const = 0;
};

class Rectangle :public Shape {
public:
virtual void draw(ShapeColor color = Green)const = 0;
};

class Circle :public Shape {
public:
virtual void draw(ShapeColor color)const = 0;
};

现在我们定义下面的代码,它们都被声明为pinter-to-Shpae类型,因此它们不论它们指向什么,静态类型都是Shape*:

动态类型是指该该对象将会有什么行为。例如:

1
2
3
Shape* ps;                 //静态类型为Shape*
Shape* pc = new Circle; //静态类型为Shape*
Shape* pr = new Rectangle; //静态类型为Shape*
1
2
3
4
5
6
Shape* ps;
Shape* pc = new Circle;//动态类型是Circle*
Shape* pr = new Rectangle;//动态类型是Rectangle*

ps = pc; //ps的动态类型如今是Circle*
ps = pr; //ps的动态类型如今是Rectangle*

根据语法我们知道,对于virtual函数的调用,是根据其动态类型决定的。例如:

1
2
3
4
5
6
Shape* ps;                 
Shape* pc = new Circle;
Shape* pr = new Rectangle;

pc->draw(Shape::Red); //调用Circle::draw(Shape::Red)
pr->draw(Shape::Red); //调用Rectangle::draw(Shape::Red)

虽然对于virtual函数的调用时动态绑定的,但是对于virtual函数的缺省参数值却是静态绑定的

见下面的代码:

  • 我们知道virtual函数是动态绑定的,pr的动态类型为Rectangle,所以调用的是Rectangle::draw()
  • 但是virtual函数的缺省参数值是静态绑定的,在上面类的定义中Rectangle的draw()函数也有默认参数,但是由于pr指针的静态类型是Shape,因此pr的draw()函数的缺省参数值就是Shape::draw()函数中的参数值,为Shape::Red。
  • 因此这个调用是派生类和基类各出一份力,基类提供默认参数,派生类提供动作。这个情况也适用于pc,注意Circle的实现并不是默认参数版本,但也因此可以认为有默认参数。
1
2
Shape* pr = new Rectangle;
pr->draw(); //调用的是Rectangle::draw(Shape::Red),而不是Rectangle::draw(Shape::Green)

以上事实不只局限于“ps,pc和pr都是指针”的情况:即使把指针换成references问题仍然存在。重点在于draw是个virtual函数,而它有个缺省参数值在derived class中被重新定义了。

为什么要设计这种行为的原因在于运行效率。如果缺省参数值也是动态绑定,编译器就必须有某种办法在运行期为virtual函数决定适当的参数缺省值,这比目前实行的“在编译期决定”的机制更慢而且更复杂。


这一切都很好,但如果你试着遵守这条规则,并且同时提供缺省参数值给base和derived classes的用户,又会发生什么事呢?

1
2
3
4
5
6
7
8
9
10
11
class Shape {
public:
enum ShapeColor { Red, Green, Blue };
virtual void draw(ShapeColor color = Red) const = 0;
...
};
class Rectangle: public Shape {
public:
virtual void draw(ShapeColor color = Red) const;
...
};

这导致了代码重复。更糟的是,代码重复又带着相依性(with dependencies):如果Shape内的缺省参数值改变了,所有“重复给定缺省参数值”的那些derived classes也必须改变,否则它们最终会导致“重复定义一个继承而来的缺省参数值”。


当你想令virtual函数表现出你所想要的行为但却遭遇麻烦,聪明的做法是考虑替代设计。

条款35列了不是virtual函数的替代设计,其中之一是NVI(non-virtual interface)手法:令base class内的一个public non-virtual函数调用private virtual函数,后者可被derived classes重新定义。这里我们可以让non-virtual函数指定缺省参数,而private virtual函数负责真正的工作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Shape {
public:
enum ShapeColor { Red, Green, Blue };
void draw(ShapeColor color = Red) const // 如今它是non-virtual
{
doDraw(color); // 调用一个virtual
}
...
private:
virtual void doDraw(ShapeColor color) const = 0; // 真正的工作在此处完成
};
class Rectangle: public Shape {
public:
...
private:
virtual void doDraw(ShapeColor color) const; // 注意,不须指定缺省参数值
...
};

由于non-virtual函数应该绝对不被derived classes覆写(见条款36),这个设计很清楚地使得draw函数的color缺省参数值总为Red。

记住:

绝对不要重新定义一个继承而来的缺省参数值,因为缺省参数值都是静态绑定,而virtual函数——你唯一应该覆写的东西——却是动态绑定。

附:注意仅仅是参数值,而非整个函数。函数是可以重新定义的,并且当使用了一个基类指针时,可以视为带了默认参数。

条款38

通过复合塑模出has-a或“根据某物实现出”。

复合(composition)是类型之间的一种关系,一个类型的对象包含其他类型对象便是这种关系:

1
2
3
4
5
6
7
8
9
10
class Address{ …… };
class PhoneNumber{ …… };
class Person{
public:
……
private:
std::string name;
Address address;
PhoneNumber mobilePhone;
};

Person对象中包含string,Address,PhoneNumber对象,这就是复合。还有几个同义词:layering(分层),containment(内含),aggregation(聚合),embedding(内嵌)。

条款 32中提到,public是is-a关系,复合是has-a(有一个)或is-implemented-in-terms-of(根据某物实现出)。在程序中,大概可以分为两个领域(domains)。程序中对象相当于你所塑造现实世界中某物,例如地址、电话号码,这样的对象属于应用域(application domain)。还有一些是实现细节上的人工复制品,例如缓冲区(buffers)、互斥器(mutexes)、查找树(search tree)等,这些是实现域(implementation domain)。

当复合发生在应用域对象之间时,表现出has-a关系;发生在实现域表现出is-implemented-in-terms-of关系。


区分is-a和is-implemented-in-terms-of比较麻烦。通过一个例子来说明,假设你需要一个template,用来构造一组classes来表示不重复对象组成的sets。首先我们想到用标准程序库提供的set template。

标准程序库的set由平衡查找树(balance search tree)实现,每个元素使用了三个指针的额外开销。这样可以使查找、插入、移除等操作时间复杂度为O(logN)(对数时间,logarithmic-time)。如果速度比空间重要,这样做合理,但是如果空间比速度重要,那么标准库提供的set将不满足我们需求.

set实现方法很多,可以在底层使用linked lists来实现,标准库中有list template,于是我们复用它。

1
2
3
4
5
template<typename T>
class Set: public std::list<T>
{
……
};

上面看起来很美好,其实是错误的。条款 32曾说过,public继承是is-a关系,即set是一种list并不对。例如set不能包含重复元素,但是list可以。

因为这两个classes之间并非is-a关系,所以public继承并不适用。正确的做法是,set对象可以根据一个list对象来实现出来:

1
2
3
4
5
6
7
8
9
10
template<calss T>
class Set{
public:
bool member(const T& item) const;
void insert(const T& item);
void remove(const T& item);
std::size_t size() const;
private:
std::list<T> rep;
};

只要熟悉list,便很快可以实现上面几个接口函数。

记住:

  • 复合(composition)的意义和public继承完全不同。
  • 在应用域(application domain),复合意味has-a;在实现域(implementation domain),复合意味is-implemented-in-terms-of(根据某物实现出)。

条款39

明智而审慎地使用private继承。

public继承是is-a关系,条款32曾讲过并给出例子,如果把那个例子用private继承会怎样?

1
2
3
4
5
6
7
8
9
10
class Person{……};
class Student: private Person{……};
void eat(const Person& p);
void study(const Student& s);

Person p;
Student s;

eat(p);
eat(s)

上面的eat(s)会出错,因为private继承不是is-a关系。如果继承关系是private,那么编译器不会自动将一个derived class对象转换为base class对象;此外继承base的所有成员,在derived class中都是private。

private继承意味implemented-in-terms-of(根据某物实现出)。如果class D以private形式继承class B,我们的用意是采用class B内已经具备的某些特性。private继承纯粹只是一种实现技术(这也是为什么derived class中,base class成员都是private的:因为它们都只是实现枝节而已)。private继承意味只有实现部分被继承,接口部分应略去。D以private形式继承B,意思是D对象是根据B对象实现而得。

private继承意味is-implemented-terms-of(根据某物实现出),和条款38的复合意义相同。那么如何在两者之间取舍?答案是尽可能的复合,必要时才使用private继承。


现在有个Widget class,我们想记录每个成员函数调用次数,在运行期间周期性审查这份信息。为了完成这项工作,需要用到定时器:

1
2
3
4
5
6
class Timer{
public:
explicit Timer(int tickFrequency);
virtual void OnTick() const;//定时器滴答一次,此函数调用一次
……
};

每次滴答调用某个virtual函数,我们可以重新定义那个virtual函数,来取出Widget当时状态。为了重新定义Timer内的virtual函数,Widget必须继承Timer。因为Widget不是Timer,因此不适用public继承。还有一个观点支持不适用public,Widget对象调用onTick有点奇怪,会违反条款18:让接口容易被正确使用,不容易被误用。

1
2
3
4
5
class Widget: private Timer{
private:
virtual void onTick() const;//查看Widget的数据等操作
……
};

这个设计也可以通过复合实现:

1
2
3
4
5
6
7
8
9
10
class Widget{
private:
class WidgetTimer: public Timer{
public:
virtual void onTick() const;
……
};
WidgetTimer timer;
……
};

这个设计稍微复杂一点,涉及到了public继承和复合,以及导入一个新class。我们有理由来选择这个复合版本,而不是private继承版本。

  • Widget可能会有派生类,但是我们可能会想阻止在派生类中重新定义onTick。如果是使用private继承,上面的想法就不能实现,因为derived classes可以重新定义virtual函数(条款35)。如果采用复用方案,Widget的derived classes将无法采用WidgetTimer(private的),自然也就无法继承或重新定义它的virtual函数了。
  • 采用复合方案,还可以降低编译依存性。如果Widget继承Timer,当Widget编译时Timer的定义必须可见,所以Widget所在的定义文件必须包含Timer的定义文件。复合方案可以将WidgetTimer移出Widget所在的文件,而让Widge只含有一个指针即可并声明WidgetTimer即可。

那么何时选择private继承呢?

private继承主要用于“当一个意欲成为derived class者想访问一个意欲成为base class者的protected成分,或为了重新定义一个或多个virtual函数”。这时候,两个classes之间关系是is-implemented-in-terms-of,而不是is-a。有一种激进情况涉及空间最优化,会促使你选择private继承,而不是继承加复合。

这个情况只适用于你所处理的class不带任何数据。它不包含non-static变量、virtual函数,没有继承virtual base class。这样的empty classes对象没使用任何空间,因为它没有任何数据对象要存储。但是因为技术原因,C++对象都必须有非零大小:

1
2
3
4
5
6
class Empty{};
class HoldsAnInt{
private:
int x;
Empty e;
};

sizeof(HoldsAnInt)>sizeof(int)。大多数编译器中,sizeof(Empty)为1,通常C++官方勒令安插一个char到对象内,但class大小还有字节对其需求(比如对齐)。

“独立(非附属)”对象大小一定不为零,这个约束不适用于derived class对象内的base成分,因为它们不独立,如果继承Empty,而不是复合:

1
2
3
4
class HoldsAnInt: private Empty{
private:
int x;
};

这时,几乎可以确定sizeof(HoldsAnInt)==sizeof(int)。这是所谓的EBO(empty base optimization;空白基类最优化)。如果客户非常在意空间,那么使用EBO。EBO一般只在单一继承下才行,统治C++对象布局的那些规则通常表示EBO无法被施行余“拥有多个base”的derived classes身上。

那么这样的empty class有什么用呢?

  • empty class并不是真的empty。它们内往往含有typedef、enum、static或弄-virtual函数。SLT有许多技术用途的empty classes,其中内含有的成员(通常是typedefs),包括base classes unary_function和binary_function,这些是“用户自定义之函数对象”,通常会继承的classes。

前面提到,只要可以尽可能选择复合,但这也不是全部。当面对并不存在is-a关系的两个classes,其中一个需要访问另一个的protected成员,或需要重新定义其一个或多个virtual函数,private继承可能成为正统设计策略。在考虑了其他方案后,仍然认为private继承是“表现两个classes之间的关系”的最佳办法,那就使用它。

记住:

  • private继承意味着is-implemented-in-terms-of(根据某物实现出)。它通常比复合(composition)的级别低。但是当derived class需要访问protected base class的成员,或需要重新定义继承而来的virtual函数时,使用private是合理的。
  • 和复合(composition)不同,private继承可以造成empty base最优化。这对致力于“对象占用空间最小化”的程序库开发者而言,可能很重要。

条款40

明智而审慎地使用多继承。

多重继承的意思是继承一个以上的base classes,但这些base classes并不常在继承体系中又有更高级的base classes。多重继承并不是继承有多个层级的意思。

使用多重继承 ,程序有可能从一个以上的基类继承相同名称(如函数,typedef等),那会导致较多的歧义。如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class BorrowableItem {		//图书馆允许你借某些东西
public:
void checkOut(); //离开时进行检查
...
};
class ElectronicGadget {
private:
bool checkOut() const; //执行自我检测,返回是否测试成功
...
};
class MP3Player: public BorrowableItem, public ElectronicGadget { //多重继承
... //类的定义不是我们关心的重点
};

MP3Player mp;
mp.checkOut(); // 歧义,此处的 checkOut 是 BorrowableItem 类的还是 ElectronicGadget 类的呢?
//即使两个函数中只有一个可访问,因为
//BorrowableItem 的 checkOut 是 public,而 ElectronicGadget 内的却是 private

C++解析重载函数调用的规则:在看到是否有函数可调用之前,C++首先确认这个函数对此调用是否是最佳匹配。找出最佳匹配才去检验可取用性。上述例子中的两个 checkOut 有相同的匹配程度(因此才造成歧义),没有所谓的最佳匹配。因此ElectronicGadget::checkOut 的可访问性也就从未被编译器审查。

为了解决歧义,必须指明你要调用哪一个 基类 内的函数:

1
2
mp.BorrowableItem::checkOut();		//OK
mp.ElectronicGadget::checkOut(); //报错,该类的 checkOut 是 private

如果继承一个以上的基类,且基类继承更高级的基类,就可以会导致菱形继承。

1
2
3
4
class File{};
class InputFile:public File{};
class OutFile:public File{};
class IOFile:public InputFile,public OutFile{};

上述继承体系中,File与IOFile之间有一条以上的相通路线。于是IOFile继承File成员时,需要面对的问题:是打算让base class内的成员变量经由每一条路径被复制(成员变量重复啦),还是说IOFile从InFile和OutFile继承的成员变量(其继承来自File)不该重复?

两个阵营,而C++在此表示中立(都可以)。于是引出虚基类的概念,即防止同一基类成员因不同相通路线而被复制多次。当然,虚继承是要付出相应代码代价。

具体做法如下,

1
2
3
4
class File {};
class InputFile :virtual public File {};
class OutFile :virtual public File {};
class IOFile :public InputFile,public OutFile {};

从正确行为的观点看,public继承 应该总是 virtual。但是正确性并不是唯一观点,为避免继承来的成员变量重复,编译器必须提供一些成本:

  • 使用 virtual 继承的那些类所产生的对象往往比使用 non-virtual 继承的兄弟们体积大
  • 访问 virtual base class 的成员变量时,比访问 non-virtual base class 的成员变量速度慢
  • 支配“virtual base class 初始化 ”的规则比 non-virtual base情况复杂且不直观

对virtual base classes的忠告:

  • 非必要不使用virtual bases。平常请使用non-virtual继承。
  • 如果必须使用virtual base classes,尽可能避免在其中放置数据。这样就不需担心classes身上的初始化(和赋值)所带来的诡异事情。

以下举例实现一个public和private并存的多重继承,public继承是is-a关系,而private继承是is implement in terms of关系,具体举例见下代码,

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
class DatabaseID{};
class IPerson {//抽象基类
public:
virtual ~IPerson();
virtual string name()const = 0;
virtual string birthDate()const = 0;
};

//factory function,根据一个独一无二的数据库ID创建一个Person对象
std::tr1::shared_ptr<IPerson> makePerson(DatabaseID personIdentifier);
DatabaseID askUserForDatabaseID();//这个函数从使用者手上取得一个数据库ID
DatabaseID id(askUserForDatabaseID());
std::tr1::shared_ptr<IPerson> pp(makePerson(id));//创建一个对象支持Iperson接口,
//借由Iperson成员函数处理*pp

//与数据库相关的class,名为PersonInfo,提供Cperson所需要的实质东西:
class PersonInfo {
public:
explicit PersonInfo(DatabaseID pid);
virtual ~PersonInfo();
virtual const char* theName()const;
virtual const char* theBirthDate()const;
virtual const char* valueDelimOpen()const;
virtual const char* valueDelimClose()const;
...
};

于是下面所要给出的CPerson和PersonInfo的关系是,PersonInfo刚好有若干函数可帮助CPerson比较容易实现出来,而IPerson则提供给CPerson接口,运用多重继承

1
2
3
4
5
6
7
8
9
10
11
12
13
class Cperson :public IPerson, private PersonInfo {
public:
explicit Cperson(DatabaseID pid):PersonInfo(pid){}
virtual string name()const {
return PersonInfo::theName();//实现必要的IPerson成员函数
}
virtual string birthDate()const {
return PersonInfo::theBirthDate();//实现必要的IPerson成员函数
}
private:
const char* valueDelimOpen()const { return ""; }//重新定义继承而来的virtual"界限函数"
const char* valueDelimClose()const { return ""; }
};

这种方法就是:将“public继承自某接口”和“private继承自某实现”结合在一起。

最后,如果有一个单一继承的设计,而它几乎等价于一个多重继承的设计方案,那么单一继承设计方案几乎一定比较受欢迎。如果你唯一能够提出的设计方案涉及多重继承,你应该更努力想一想——几乎可以说一定会有某些方案让单一继承行得通。然而多重继承有时候的确是完成任务之最简结、最易维护、最合理的做法,果真如此就别害怕使用它。

记住:

  • 多重继承比单一继承复杂。它可能导致新的歧义性、以及对virtual继承的需要。
  • virtual继承会增加大小、速度、初始化(及赋值)复杂度等等成本。如果virtual base classes不带任何数据,将是最具实用价值的情况。
  • 多重继承的确有正当用途。其中一个情节涉及“public继承某个Interface class”和“private继承某个协助实现的class”的两相组合。

条款41

了解隐式接口和编译期多态。

面向对象编程总是以显式接口和运行期多态来解决问题。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Widget{ 
public:
Widget();
virtual ~Widget();
virtual std::size_t size() const;
virtual void normalize();
virtual swap(Widget& other);
};

void doProcessing(Widget& w)
{
if (w.size() > 10 && w != someNastyWidget){
Widget temp(w);
temp.normalize();
temp.swap(w);
}
}
  • 所谓的显式接口:由于w的类型被声明为Widget,因此w需要Widget接口,并且我们可以在源码中找到这个接口,看到源码的样子,所以称为是显式接口。
  • 所谓的运行期多态:由于Widget的某些函数是虚函数,因此w的某些函数在运行期间才可以根据w的类型动态调用相关版本的函数,这就是所谓的运行期多态。

在泛型编程中,显式接口与运行期多态仍有使用,但是其主要应用的是隐式接口和编译期多态。

例如将刚才的函数改为函数模板(现在,在实例化前无法去找相应的源码中的接口):

1
2
3
4
5
6
7
8
9
template<typename T> 
void doProcessing(T& w) //w需要支持的操作都是隐式接口
{
if (w.size() > 10 && w != someNastyWidget){
T temp(w);
temp.normalize();
temp.swap(w);
}
}

这个时候w发生了什么样的改变呢?

  • 隐式接口:w所需要支持的接口需要当函数模板具现化时执行于w身上的操作决定(执行了什么操作,说明w一定需要支持这些接口),例子中w使用了size、normalize、swap函数、copy构造函数、不等比较。并且if语句中还有一个长表达式。这所有的函数与长表达式便是T必须支持的一组隐式接口(其实就是w需要被约束的东西)。(w.size() > 10 && w != someNastyWidget)
  • 编译期多态:使用到w的任何函数调用,都可能会造成模板具现化,这样的函数具现化发生在编译期,而且不同的模板参数导致不同的模板函数,这就是所谓的编译期多态。

通常显式接口是由函数的签名式(函数名称、参数类型、返回类型)构成。但是隐式接口不是基于签名式的,而是由有效表达式组成。

1
w.size()>10&&w!=someNastyWidget//这就是所谓的隐式接口,是一组有效表达式。

w的隐式接口似乎有下述的约束:

  • 提供size()函数,返回整数值
  • 支持!= 操作符重载,用来比较两个T对象

但是由于操作符重载的关系,隐式接口实际上不需要满足这两个约束。原因如下:

  • w可能继承自base class的size 函数,因此不需要有size函数
  • 并且size函数也没必要返回一个整数,只要它能够返回一个类型为X的对象,并且X和10 能够调用> 符号函数即可。
  • ‘>’不需要非得是对象X的成员函数(可以是全局的一个函数。)
  • 再退一步,并且符号函数>也并不是非得取得一个X对象和一个10才可以,它也可以取得类型Y的参数,只要存在一个隐式转换能够将类型X的对象转换为类型Y的对象。

同样,T不必支持operator!=,因为operator!=也可以接受类型为X和Y的对象,只要T可以被转换为X,someNastyWidget的类型可以被转换成Y就行。

总之,隐式接口就是一组表达式,不管中间过程怎么样,只要最终的结果是一个满足类似于上述if语句中的表达式应该有的结果就行,比如if的条件表达式应该是bool类型的,只要括号里的表达式最终的结果是bool类型即可。表达式中间的接口可能并不需要w去支持。这些就是所谓的隐式接口。

记住:

  • classes和templates都支持接口和多态。
  • 对classes而言接口是显式的,以函数签名为中心。多态则是通过virtual函数发生于运行期。
  • 对template参数而言,接口是隐式的,奠基于有效表达式。多态则是通过template具现化和函数重载解析发生于编译期。

条款42

了解typename的双重意义。

在模板的的声明中,class与typename是没有什么区别的:

1
2
template <typename T> T func1(const T&);
template <class T> T func2(const T&);

但是在模板的定义中typename有时候却会派上用场。为了说明问题,我们先了解一下:模板中依赖于模板参数的名称称为从属名称(dependent name), 当一个从属名称嵌套在一个类里面时,称为嵌套从属名称(nested dependent name)。 其实C::const_iterator还是一个嵌套从属类型名称(nested dependent type name)。

有了这两个基本概念之后我们就可以看一下例子:假设我们要打印一个容器(里面为)中的第二个元素,那么函数应该是这样:

1
2
3
4
5
6
7
8
9
10
template<typename C>
void print2nd(const C& container) // 打印容器内第二个元素
{ // 注意这不是有效C++代码
if (container.size() >= 2) {
C::const_iterator iter(container.begin()); // 取得第一元素的迭代器
++iter; // 将iter移往第二元素
int value = *iter; // 将该元素复制到某个int
std::cout << value; // 打印那个int
}
}

在代码中特别强调两个local变量和itemvalue。iter的类型是C::const_iterator,实际是什么必须取决于template参数C。

print2nd内的另一个local变量value,其类型是int。int是一个并不依赖任何template参数的名称。这样的名称是非从属名称。

嵌套从属名称有可能导致解析困难。举个例子,假设我们令print2nd更愚蠢些,这样起头:

1
2
3
4
5
6
template<typename C>
void print2nd(const C& container)
{
C::const_iterator* x;
...
}

看起来好像我们声明x为一个local变量,它是个指针,指向一个C::const_iterator。但它之所以被那么认为,只因为我们“已经知道”C::const_iterator是个类型。如果C有个static成员变量而碰巧被命名为const_iterator,或如果x碰巧是个global变量名称呢?那样的话上述代码就不再是声明一个local变量,而是一个相乘动作:C::const_iterator乘以x。当然,这听起来有点疯狂,但却是可能的,而撰写C++解析器的人必须操心所有可能的输入,甚至是这么疯狂的输入。


在我们知道C是什么之前,没有任何办法可以知道C::const_iterator是否为一个类型。而当编译器开始解析template print2nd时,尚未确知C是什么东西。C++有个规则可以解析此一歧义状态:如果解析器在template中遭遇一个嵌套从属名称,它便假设这个名称不是个类型,除非你告诉它是。所以缺省情况下嵌套从属名称不是类型。此规则有个例外,稍后会提到。

现在再次看看print2nd起始处:

1
2
3
4
5
6
template<typename C>
void print2nd(const C& container)
{
if (container.size() >= 2) {
C::const_iterator iter(container.begin()); // 这个名称被假设为非类型
...

现在应该很清楚为什么这不是有效的C++代码了吧。iter声明式只有在C::const_iterator是个类型时才合理,但我们并没有告诉C++说它是,于是C++假设它不是。若要矫正这个形势,我们必须告诉C++说C::const_iterator是个类型。只要紧临它之前放置关键字typename即可:

1
2
3
4
5
6
template<typename C>                 // 这是合法的C++代码
void print2nd(const C& container)
{
if (container.size() >= 2) {
typename C::const_iterator iter(container.begin());
...

一般性规则很简单:任何时候当你想要在template中指涉一个嵌套从属类型名称,就必须在紧临它的前一个位置放上关键字typename。

typename只被用来验明嵌套从属类型名称:其他名称不该有它存在。例如下面这个function template,接受一个容器和一个“指向该容器”的迭代器:

1
2
3
template<typename C>                     // 允许使用“typename”(或“class”)
void f(const C& container, // 不允许使用“typename”
typename C::iterator iter); // 一定要使用“typename”

上述的C并不是嵌套从属类型名称(它并非嵌套于任何“取决于template参数”的东西内),所以声明container时并不需要以typename为前导,但C::iterator是个嵌套从属类型名称,所以必须以typename为前导。

“typename必须作为嵌套从属类型名称的前缀词”这一规则的例外是,typename不可以出现在base classes list内的嵌套从属类型名称之前,也不可在member initialization list(成员初始列)中作为base class修饰符。例如:

1
2
3
4
5
6
7
8
9
10
11
template<typename T>
class Derived: public Base<T>::Nested { // base class list中不允许“typename”
public:
explicit Derived(int x)
: Base<T>::Nested(x) // mem.init.list中不允许“typename”
{
typename Base<T>::Nested temp; // 嵌套从属类型名称,
... // 既不在base class list中也不再mem.init.list中,
} // 作为一个base class修饰符需加上typename
...
};

记住:

  • 声明template参数时,前缀关键字class和typename可互换。
  • 请使用关键字typename标识嵌套从属类型名称;但不得在base class lists(基类列)或member initialization list(成员初值列)内以它作为base class修饰符。

条款43

学习处理模板化基类内的名称。

我们需要一个程序,传送信息到不同的公司去。信息要不译成密码,要不就是未加工的文字。如果编译期间我们有足够信息来决定哪一个信息传至那一家公司,就可以采用基于 template 的解法:

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
class CompanyA{ 
public:
void sendCleartext(const std::string& msg);
void sendEncrypted(const std::string& msg);
};

class CompanyB{
public:
void sendCleartext(const std::string& msg);
void sendEncrypted(const std::string& msg);
};

... //针对其他公司设计的classes

class MsgInfo{...}; //这个class用来保存信息,以备将来产生信息

template<typename Company>
class MsgSender{
public:
void sendClear(const MsgInfo& info)
{
std::string msg;
根据info产生信息;
Company c;
c.sendCleartext(msg);
}

void sendSecret(const MsgInfo& info)
{
...;//调用c.sendEncrypted,类似sendClear
}
};

这个做法行的通。假设我们有时候想要在每次发送出信息的时候志记(log)某些信息。 derived class 可以轻易加上这样的行为,那似乎是个合情理的解法:

1
2
3
4
5
6
7
8
9
10
template<typename Company> 
class LoggingMsgSender: public MsgSender<Company>{
public:
void sendClearMsg(const MsgInfo& info)
{
将传送前信息写至log;
sendClear(info);
将传送后信息写至log;
}
};

sendClearMsg 避免遮掩 “继承而得的名称”(条款 33),避免重新定义一个继承而得的 non-virtual 函数(条款 36)。但上述代码无法通过编译,编译器看不到 sendClear。

问题在于,编译器遇到 class template LoggingMsgSender 定义式时,并不知道它继承什么样的 class。因为 MsgSender<Company> 中的 Company 是个 template 参数,不到后来(当 LoggingMsgSender 被具现化)无法确切知道它是什么。而如果不知道 Company 是什么,就无法知道 class MsgSender<Company> 看起来是个什么样 —— 更明确的说是没办法知道它是否有个 sendClear 函数。


为了让问题具体化,假设有个 class CompanyZ 只是用加密通信:

1
2
3
4
class CompanyZ { 
public:
void sendEncrypted(const std::string& msg);
};

一般性的 MsgSender template 对 CompanyZ 并不合适,因为那个 template 提供了一个 sendClear 函数(其中针对其类型参数 Company 调用了 sendCleartext 函数),而这对 CompanyZ 对象并不合理。与纠正这个问题,我们可以针对 CompanyZ 产生一个 MsgSender 特化版;

1
2
3
4
5
6
7
8
template<>                                            //一个全特化的 
class MsgSender<CompanyZ>{ // MsgSender;它和一般 template 相同
public: //差别只在于它删掉了 sendClear
void sendSecret(const MsgInfo& info)
{
...
}
};

注意 class 定义式最前头 “template<>” 语法象征这既不是 template 也不是标准 class,而是个特化版的 MsgSender template,在 template 实参是 CompanyZ 时被使用。这事模板全特化(total template specialization):template MsgSender 针对类型 CompanyZ 特化了,而且其特化是全面性的,也就是说一旦类型参数被定为 CompanyZ,再没有其他 template 参数可供变化。

1
2
3
4
5
6
7
8
9
10
template<typename Company> 
class LoggingMsgSender: public MsgSender<Company>{
public:
void sendClearMsg(const MsgInfo& info)
{
将传送前信息写至log;
sendClear(info);
将传送后信息写至log;
}
};

那就是为什么 C++ 拒绝这个调用的原因:它知道 base class template 可能被特化,而那个特化版本可能不提供和一般属性 template 相同的接口。因此它往往拒绝在 templatized base class(模板化基类,MsgSender<Company>)内寻找继承而来的名称(本例的 SendClear)。从 Object Oriented C++ 跨进 Template C++ 继承就不想以前那般畅通无阻了。


我们必须令 C++ “进入 templatized base classes 观察”。有三个办法:

  • 第一个办法是 base class 函数调用动作之前加上 “this->”:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    template<typename Company> 
    class LoggingMsgSender: public MsgSender<Company>{
    public:
    void sendClearMsg(const MsgInfo& info)
    {
    将传送前信息写至log;
    this->sendClear(info); //成立,假设sendClear将被继承
    将传送后信息写至log;
    }
    };
  • 第二个办法是使用 using 声明式:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    template<typename Company> 
    class LoggingMsgSender: public MsgSender<Company>{
    public:
    using MsgSender<Company>::sendClear; // 告诉编译器,请他假设 sendClear 位于 base class 内
    void sendClearMsg(const MsgInfo& info)
    {
    将传送前信息写至log;
    sendClear(info); //成立,假设sendClear将被继承
    将传送后信息写至log;
    }
    };
  • 第三个做法是,明白指出被调用的函数位于 base class 内:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    template<typename Company> 
    class LoggingMsgSender: public MsgSender<Company>{
    public:
    void sendClearMsg(const MsgInfo& info)
    {
    将传送前信息写至log;
    MsgSender<Company>::sendClear(info); //成立,假设sendClear将被继承
    }
    };

    但这往往不是令人满意的一个解法,因为如果被调用的是 virtual 函数,上述的明确资格修饰 MsgSender:: 会关闭 virtual 绑定行为。

从名称可视点的角度出发,上述每个解法做的事情都相同:对编译器承诺 “base class template 的任何特化版本都将支持其一般化版本所提供的接口”。这样一个承诺是编译器在解析(parse)像 LoggingMsgSender 这样的 derived class template 时需要的。但如果这个承诺最终未被实践出来,往后的编译器最终还是会给事实一个公道。例如,如果稍后的源码内含这个:

1
2
3
LoggingMsgSender<CompanyZ> zMsgSender; 
MsgInfo msgData;
zMsgSender.sendClearMsg(msgData); // 错误!无法通过编译。

因为在那个点上,编译器知道 base class 是个 template 特化版本 MsgSender<CompanyZ>,而它们知道那个 class 不提供 sendClear 函数,而这个函数却是 sendClearMsg 尝试调用的函数。

根本而言,面对 “指涉 base class members” 之无效的 references,编译器的诊断时间可能发生在早期(当解析 derived class template 的定义式时),也可能发生在晚期(当那些 templates 被特定之 template 实参具现化时)。C++ 的政策是宁愿早诊断。这就是为什么 “当 base classes 从 templates 中被具现化时” 它假设它对那 base classes 的内容毫无所悉的缘故。

记住:

可在 derived class template 内通过 “this->” 指涉 base class template 内的成员名称,或藉由一个明白写出的 “base class 资格修饰符” 完成。

附:以及using声明

条款44

将与参数无关的代码抽离templates。

templates 是节省时间和避免代码重复的奇方妙法。你不再需要键入 20 个类似的 classes 并且每一个都带有 20 个 成员函数,你只需要键入一个 class template,留给编译器去具现化那 20 个你需要的相关 classes 即可,而且对于 20 个函数中未被调用的,编译器不会自动生成。

但是,这也很容易使得代码膨胀(code bloat),templates 产出码带着重复,或者几乎重复的代码,数据,或者两者。你可以通过:共性与变形分析(commonality and variability analysis)来避免代码膨胀。

这个概念其实你早在使用,即使你从未写过一个 templates。当你编写某个函数时,你明白其中某些部分的实现码和另一个函数的实现码实质相同,你会很单纯的重复它们吗?当然不,你会抽出这两个函数相同的部分,放进第三个函数中,然后令原先两个函数调用这个新函数。也就是说:你分析了两个函数的共性和变形,把公共的部分搬到一个新的函数中去,变化的部分保留在原来的函数不动。对于 class 也是这个道理,如果你明白某些 class 和另一个 class 具有相同的部分,你也会把共性搬到一个新的 class。


templates 的优化思路也是如此,以相同的方式避免重复,但其中有个窍门。在 non-template 代码中,重复很明确。然而在 template 代码中,重复是隐晦的,毕竟只存在一份 template 代码,所以你必须自己去感受 template 具现化时可能发生的重复。

一种情况是 template class 成员依赖 template 参数值,举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename T,size_t N>
class SquareMatrix {
public:
void Invert() const{
//...
}
};
inline void TryWithMatrix() {
SquareMatrix<int,5>SquareMatrixFive;
SquareMatrixFive.Invert();
SquareMatrix<int,10>SquareMatrixTen;
SquareMatrixTen.Invert();
}

上述代码用于正方形矩阵求逆矩阵,其 template 接受一个类型参数 T 作为元素类型外,还接受一个类型为 size_t 的参数作为矩阵大小,这是非类型参数(non-type parameter)。这种参数不常见,但它们完全合法,而且相当自然。

在 TryWithMatrix 函数中,我们分别对 5*5 大小和 10*10 大小的矩阵求逆,但除了常量 5 和 10,其他函数的操作部分完全相同,但因为 template 参数不同的,编译器仍然会会具现化两份函数,这是 template 引起代码膨胀的典型例子。

下面是对 SquareMatrix 的一次修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<typename T>
class SquareMatrixBase {
protected:
void Invert(std::size_t InSize);
};
template<typename T,size_t N>
class SquareMatrix:private SquareMatrixBase<T>{
public:
using SquareMatrixBase<T>::Invert; // 避免遮掩继承自 base 版的 Invert 函数
void Invert(){
this->Invert(N); //inline的转调用,基类的不是inline,这才使得代码不会膨胀,如果基类也inline,则没有意义
//因为在使用时若是inline,还是会重复代码
}
};

就如你所看到的,带参数的 Invert 位于 base class SquareMatrixBase 中。和 SquareMatrix 一样,它也是个 template,不同的是它只对矩阵元素类型参数化,不对矩阵尺寸参数化,因此对于某给定的元素对象,所有的矩阵会共享唯一一个 SquareMatrixBase class,因此它们也因此共享这唯一一个 class 内的 Invert,从而避免了代码不必要的重复。如果元素类型与逆矩阵计算无关的话,甚至可以不对矩阵元素参数化,从而使得所有元素类型共享唯一一份 Invert 函数代码。

  • SquareMatrixBase::invert只是企图成为”避免derived classes代码重复”的一种方法,所以它以protected替换public。
  • 这些函数使用this->记号,因为若不这样做,便如条款43所说,模板化基类内的函数名称会被derived classes掩盖。(实际上感觉using已经完成了)
  • SquareMatrix和SquareMatrixBase之间的继承关系是private。这反应一个事实:这里的base class只是为了帮助derived classes实现,不是为了表现SquareMatrix和SquareMatrixBase之间的is-a关系。

SquareMatrixBase::invert如何知道该操作什么数据?虽然它从参数中知道矩阵尺寸,但它如何知道哪个特定矩阵的数据在哪里呢。

一个可能的做法是为SquareMatrixBase::invert添加另一个参数,也许是个指针,指向一块用来放置矩阵数据的内存起始点。那行得通,但十之八九invert不是唯一一个可写为”形式与尺寸无关并可移至SquareMatrixBase内”的”SquareMatrix函数。如果有若干这样的函数,我们唯一要做的就是找出保存矩阵元素值的那块内存。我们可以对所有这样的函数添加一个额外参数,却得一次又一次地告诉SquareMatrixBase相同的信息,这样做不是很好。

可以令SquareMatrixBase贮存一个指针,指向矩阵数值所在的内存。而只要它存储了那些东西,也就可能存储矩阵的尺寸。

1
2
3
4
5
6
7
8
9
10
11
template<typename T>
class SquareMatrixBase {
protected:
SquareMatrixBase(std::size_t n, T* pMenu)
:size(n), pData(pMem) { }
void setDataPtr(T* ptr) { pData = ptr; }
...
private:
std::size_t size;
T* pData;
};

这允许derived class决定内存的分配方式,某些实现版本也许会决定将矩阵数据存储在SquareMatrix对象内部:

1
2
3
4
5
6
7
8
9
template<typename T, std::size_t n>
class SquareMatrix: private SquareMatrixBase<T> {
public:
SquareMatrix(): SquareMatrixBase<T>(n, data) // 送出矩阵大小和数据指针给base class
{ }
...
private:
T data[n*n];
};

这种类型的对象就不需要动态分配内存,但对象自身可能非常大。另一种做法就是把每一个矩阵的数据放进heap(也就是通过new来分配内存)

1
2
3
4
5
6
7
8
9
template<typename T, std::size_t n>
class SquareMatrix: private SquareMatrixBase<T> {
public:
SquareMatrix(): SquareMatrixBase<T>(n, 0), pData(new T[n*n]) // 将base class的数据指针设为null,为矩阵内容分配内存
{ this->setDataPtr(pData.get()); } // 将指向该内存的指针存储起来,然后将它的一个副本交给base class
...
private:
boost::scoped_array<T> pData;
};

但注意,之前直接使用模板参数的 Invert 函数,有可能产出比上面共享版本更好的代码,因为模板尺寸是一个编译器常量,因此可以像常量那样被直接生成到指令中成为立即操作数,达到更优化。

从另外一个角度来看,拥有共享的 Invert 函数,可减少执行文件的大小,降低了所需的内存,也提高了高速缓存命中率。这些都可能使得程序执行更快速。

哪一个影响占主要地位?需要进行实际的平台测试和观察面对代表性数据的行为。

类型参数(type parameters)也会导致代码膨胀。例如在许多平台上 int 和 long 二进制表示完全相同,所以像 vector<int> 和 vector<long> 有着相同的代码实现。某些链接器(linkers)会合并完全相同的函数实现码,但有些不会,后者意味着某些 templates 将具现化为 int 和 long 两个版本,从而造成代码膨胀。类似,在大多数平台上,所有指针类型都具有相同的二进制表示,因此凡是 template 拥有指针的,往往应该对每一个函数使用唯一一份底层实现。这很具代表性质,如果你实现某些成员函数而它们操作强型指针(strongly typed pointer)T*,你应该令它们调用另一个操作无类型的指针(untyped pointers,即 void*)的函数,由后者完成实际的工作。

记住:

  • Templates 生成多个 classes 和多个 functions,所以任何 template 代码都不该与某个造成膨胀的 template 参数产生相依关系。
  • 因非类型模板参数(non-type template parameters)而造成的代码膨胀,往往可以消除,做法是以函数参数或 class 成员变量替换 template 参数。
  • 因类型参数(type parameters)而造成的代码膨胀,往往可以降低,做法是让带有完全相同二进制表述(binary representations)的具现类型(instantiation types)共享实现码。

条款45

运用成员函数模板接受所有兼容类型。

从例子入手,所谓智能指针(smart pointer),是行为像指针的对象,并提供指针没有的机能:自动管理资源。但原始指针(raw pointer)做的很好的一件事是:支持隐式转换(implicit conversions)。比如 derived class 指针可以隐式转换为 base class 指针,指向 non-const 的指针可以转换为 指向 const 的指针…下面是可能发生于三层继承体系的一些转换:

1
2
3
4
5
6
class Top { ... };
class Middle: public Top { ... };
class Bottom: public Middle { ... };
Top* pt1 = new Middle; // 将Middle*转换为Top*
Top* pt2 = new Bottom; // 将Bottom*转换为Top*
const Top* pct2 = pt1; // 将Top*转换为const Top*

但如果想在用户自定的智能指针中模拟上述转换,稍稍有点麻烦。我们希望以下代码通过编译:

1
2
3
4
5
6
7
8
9
template<typename T>
class SmartPtr {
public: // 智能指针通常以内置指针完成初始化
explicit SmartPtr(T* realPtr);
...
};
SmartPtr<Top> pt1 = SmartPtr<Middle>(new Middle); // 将SmartPtr<Middle>转SmartPtr<Top>
SmartPtr<Top> pt2 = SmartPtr<Bottom>(new Bottom); // 将SmartPtr<Bottom>转SmartPtr<Top>
SmartPtr<const Top> pct2 = pt1; // 将SmartPtr<Top>转SmartPtr<const Top>

由于同一template的不同实例化之间没有直接联系,也就是说对于自定义的智能指针(假设名为SmartPtr),如果不额外采取手段支持基层层次中派生类指针向基类指针的转换,那么SmartPtr<Base>和SmartPtr<Derived>将会被编译器认为毫无关联,也就不存在SmartPtr<Derived>向SmartPtr<Base>的隐式转换。

在上述智能指针实例中,每一个语句创建了一个新式智能指针对象,所以现在我们应该关注如何编写智能指针的构造函数,使其行为能够满足我们转型需要。一个很关键的观察结果是:我们永远无法写出我们需要的所有构造函数。在上述继承体系中,我们根据一个SmartPtr<Middle>或一个SmartPtr<Bottom>构造出一个SmartPtr<Top>,但如果这个继承体系未来有所扩充,SmartPtr<Top>对象又必须能够根据其他智能指针构造自己。假设日后添加了:

1
class BelowBottom: public Bottom { ... };

我们因此必须令SmartPtr<BelowBottom>对象得以生成SmartPtr<Top>对象,但我们当然不希望一再修改SmartPtr template以满足此类需求。


就原理而言,此例中我们需要的构造函数数量没有止尽,因为一个template可被无限量具现化,已致生成无限量函数。因此,似乎我们需要的不是为SmartPtr写一个构造函数,而是为它写一个构造模板。这样的模板是所谓member function templates,其作用是为class生成函数:

1
2
3
4
5
6
7
template<typename T>
class SmartPtr {
public:
template<typename U> // member template,
SmartPtr(const SmartPtr<U>& other); // 未来生成copy构造函数
...
};

以上代码的意思是,对任何类型T和任何类型U,这里可以根据SmartPtr<U>生成一个SmartPtr<T>——因为SmartPtr<T>有个构造函数接受一个SmartPtr<U>参数。这一类构造函数根据对象U创建对象T,而U和T的类型是同一个template的不同具现体,有时我们称之为泛化copy构造函数。

上面的泛化copy构造函数并未被声明为explicit。那是蓄意的,因为原始指针类型之间的转换(例如从derived class指针转为base class指针)是隐式转换,无需明白写出转型动作(cast),所以让智能指针效仿这种行径也属合理。在模板化构造函数中略去explicit就是为了这个目的。


完成声明之后,这个为SmartPtr而写的“泛化copy构造函数”提供的东西比我们需要的更多。是的,我们希望根据一个SmartPtr<Bottom>创建一个SmartPtr<Top>,却不希望根据一个SmartPtr<Top>创建一个SmartPtr<Bottom>(根据基类创建派生类),因为那对public继承而言(见条款32)是矛盾的。我们也不希望根据一个SmartPtr<double>创建一个SmartPtr<int>,因为现实中并没有“将int* 转换为double*”的对应隐式转换行为。是的,我们必须从某方面对这一member template所创建的成员函数群进行筛除。

假设SmartPtr遵循auto_ptr和tr1::shared_ptr所提供的榜样,也提供一个get成员函数,返回智能指针对象(见条款15)所持有的那个原始指针的副本,那么我们可以在“构造模板”实现代码中约束转换行为,使它符合我们的期望:

1
2
3
4
5
6
7
8
9
10
11
template<typename T>
class SmartPtr {
public:
template<typename U>
SmartPtr(const SmartPtr<U>& other) // 以other的heldPtr初始化this的heldPtr
: heldPtr(other.get()) { ... }
T* get() const { return heldPtr; }
...
private:
T* heldPtr; // 这个SmartPtr持有内置指针
};

使用成员初值列来初始化SmartPtr<T>之内类型为T*的成员变量,并以类型为U*的指针(由SmartPtr<U>持有)作为初值。这个行为只有当“存在某个隐式转换可将一个U*指针转为一个T*指针”时才能通过编译,而那正是我们想要的。最终效益时SmartPtr<T>现在有了一个泛化copy构造函数,这个构造函数只在其所获得的实参隶属适当(兼容)类型时才通过编译。


member function templates的效用不限于构造函数,它们常扮演的另一个角色是支持赋值操作。例如TR1的shared_ptr(见条款13)支持所有“来自兼容之内置指针、tr1::shared_ptrs、auto_ptrs和tr1::weak_ptrs(见条款54)”的构造行为,以及所有来自上述各物(tr1::weak_ptrs除外)的赋值操作。下面是TR1规范中关于tr1::shared_ptr的一份摘录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template<class T>
class shared_ptr {
public:
template <class Y>
explicit shared_ptr(Y* p); // 构造,来自任何兼容的内置指针
template <class Y>
shared_ptr(shared_ptr<Y> const& r); // 或shared_ptr
template <class Y>
explicit shared_ptr(weak_ptr<Y> const& r); // 或weak_ptr
template <class Y>
explicit shared_ptr(auto_ptr<Y>& r); // 或auto_ptr
template <class Y>
shared_ptr& operator=(shared_ptr<Y> const& r); // 赋值,来自任何兼容的shared_ptr
template <class Y>
shared_ptr& operator=(auto_ptr<Y>& r); // 或auto_ptr
...
};

上述所有构造函数都是explicit,唯有“泛化copy构造函数”除外。那意味从某个shared_ptr类型隐式转换至另一个shared_ptr类型是被允许的,但从某个内置指针或从其他智能指针类型进行隐式转换则不被认可(如果是显示转换和cast强制转型动作倒是可以)。另一个趣味点是传递给tr1::shared_ptr构造函数和assignment操作符的auto_ptrs并未被声明为const,与之形成对比的则是tr1::shared_ptrs和tr1::weak_ptrs都以const传递。这是因为条款13说过,当你复制一个auto_ptrs,它们其实被改动了。

member templates并不改变语言规则,而语言规则说,如果程序需要一个copy构造函数,你却没有声明它,编译器会为你暗自生成一个。在class内声明泛化copy构造函数并不会阻止编译器生成它们自己的copy构造函数,所以如果你想要控制copy构造函数的方方面面,你必须同时声明泛化copy构造函数和“正常的”copy构造函数。相同规则也适用于赋值操作。下面是tr1::shared_ptr的一份定义摘要,例证上述所言:

1
2
3
4
5
6
7
8
9
10
11
12
13
template<class T>
class shared_ptr {
public:
shared_ptr(shared_ptr const& r); // copy构造函数

template <class Y>
shared_ptr(shared_ptr<Y> const& r); // 泛化copy构造函数

shared_ptr& operator=(shared_ptr<Y> const& r); // copy assignment
template <class Y>
shared_ptr& operator=(shared_ptr<Y> const& ); // 泛化copy assignment
...
};

记住:

  • 请使用member function templates(成员函数模板)生成“可接受所有兼容类型”的函数。
  • 如果你声明member templates用于“泛化copy构造”或“泛化assignment操作”,你还是需要声明正常的copy构造函数和copy assignment操作符。

条款46

需要类型转换时请为模板定义非成员函数。

条款24讨论过为什么唯有non-member函数才有能力“在所有实参身上实施隐式类型转换”,该条款并以Rational class的operator*函数为例。

1
2
3
4
5
6
7
8
9
10
11
template<typename T>
class Rational {
public:
Rational(const T& numberator = 0, const T& denominator = 1);
const T numerator() const;
const T denominator() const;
...
};
template<typename T>
const Rational<T> operator*(const Rational<T>& lhs, const Rational<T>& rhs)
{ ... }

像条款24一样,我们希望支持混合式算数运算,所以我们希望以下代码顺利通过编译。我们也预期它会,因为它正是条款24所列的同一份代码,唯一不同的是Rational和operator*如今都成了templates:

1
2
Rational<int> oneHalf(1, 2);
Rational<int> result = oneHalf * 2; // 错误!无法通过编译

上述失败给我们的启示是,模板化的Rational内的某些东西似乎和其non-template版本不同。事实的确如此。在条款24内,编译器知道我们尝试调用什么函数(就是接受两个Rationals参数的那个operator*),但这里编译器不知道我们想要调用哪个函数。取而代之的是,它们试图想出什么函数被名为operator*的template具现化出来。它们知道它们应该可以具现化某个“名为operator*并接受两个Rational<T>参数”的函数,但为完成这一具现化行动,必须先算出T是什么。问题是它们没这个能耐。


为了推导T,它们看了看operator*调用动作中的实参类型。本例中那些类型分别是Rational<int>(oneHalf的类型)和int(2的类型)。每个参数分开考虑。

以oneHalf进行推导,过程并不困难。operator*的第一个参数被声明为Rational<T>,而传递给operator*的第一实参(oneHalf)的类型是Rational<int>,所以T一定是int。其他参数的推导则没有这么顺利。operator*的第二参数被声明为Rational<T>,但传递给operator*的第二实参(2)类型是int。

编译器如何根据这个推算出?你或许会期盼编译器使用Rational<int>的non-explicit构造函数将2转换为Rational<int>,进而将T推导为int,但它们不那么做,因为在template实参推导过程中从不将隐式类型转换函数纳入考虑(因为这样的转换在函数调用过程中出现,但在调用之前,函数首先得存在)。


只要利用一个事实,我们就可以缓和编译器在template实参推导方面受到的挑战:template class内的friend声明式可以指涉某个特定函数。那意味Rational<T>可以声明operator*是它的一个friend函数。Class templates并不依赖template实参推导(后者只施行于function templates身上),所以编译器总是能够在class Rational<T>具现化时得知T。因此,令Rational<T> class声明适当的operator*为其friend函数,可简化整个问题:

1
2
3
4
5
6
7
8
9
10
11
template<typename T>
class Rational {
public:
...
friend
const Rational operator*(const Rational& lhs, const Rational& rhs); //这个函数与后面的函数并不相同,这个声明
//并不是后面那个函数的声明,这个函数的T已经和类绑定在一起了,因此不是相同的函数
}; //所以也没有定义式
template<typename T>
const Rational<T> operator*(const Rational<T>& lhs, const Rational<T>& rhs)
{ ... }

现在对operator的混合式调用可以通过编译了,因为当对象 oneHalf被声明为一个Rational<int>, class Rational<int>于是被具现化出来,而作为过程的一部分,friend函数operator*(接受Rational<int>参数)也就被自动声明出来。后者身为一个函数而非函数模板,因此编译器可在调用它时使用隐式转换函数(例如Rational的non-explicit构造函数),而这便是混合式调用之所以成果的原因。

但是,此情境下的“成功”是个有趣的字眼,因为虽然这段代码通过编译,却无法连接。稍后我马上回来处理这个问题,首先我要谈谈在Rational内声明operator*的语法。

在一个class template内,template名称可被用来作为“template和其参数”的简略表达方式,所以在Rational<T>内我们可以只写Rational而不必写Rational<T>。本例中这只节省我们少打几个字,但若出现许多参数,或参数名称很长,这可以节省我们的时间,也可以让代码比较干净。我谈这个是因为,本例中的operator*被声明为接受并返回Rationals(而非Rational<T>)。如果它被声明如下,一样有效:

1
2
3
4
5
6
7
8
template<typename T>
class Rational {
public:
...
friend
const Rational<T> operator*(const Rational<T>& lhs, const Rational<T>& rhs);
...
};

然而使用简略表达式比较轻松也比较普遍。


现在回头想想我们的问题。混合式代码通过了编译,因为编译器知道我们要调用哪个函数,但哪个函数只被声明于Rational内,并没有被定义出来。我们意图令此class外部的operator* template提供定义式,但是行不通——如果我们自己声明了一个函数,就有责任定义那个函数。既然我们没有提供定义式,连接器当然找不到它!

或许最简单的可行办法就是将operator*函数本体合并至其声明式内:

1
2
3
4
5
6
7
8
9
10
template<typename T>
class Rational {
public:
...
friend
const Rational operator*(const Rational& lhs, const Rational& rhs)
{
return Rational(lhs.numberator() * rhis.numberator(), lhs.denominator() * rhs.denominator());
}
};

这便如同我们所期望地正常运作了起来:对operator*的混合式调用现在可以编译并执行。

这项技术的趣味点是,虽然我们使用 friend 关键字,却和其传统用途:访问 class 的 non-public 成分不同。我们是为了让类型转换发生在所有可能的实参上,我们需要一个 non-member 函数;而为了使这个函数自动具现化,我们需要将它声明在 class 内部,而在 class 内部声明 non-member 函数的唯一有效方法就是:令它成为一个 friend。

一如条款30所说,定义于 class 内的函数都将暗自 inline,所以一个更好的做法是:令该 friend 函数调用另一个辅助函数(减少代码膨胀):

1
2
3
4
5
6
7
8
9
10
11
12
13
template <typename T>
class Rational {
public:
friend Rational operator*(const Rational& RationalOne, const Rational& RationalTwo) {
return OnMultiply(RationalOne,RationalTwo);
}
//...
};
template <typename T>
Rational<T> OnMultiply(const Rational<T>& RationalOne, const Rational<T>& RationalTwo){
return Rational<T>(RationalOne.numberator() * RationalTwo.numberator(),
RationalOne.numberator() * RationalTwo.numberator());
}

记住:

当我们编写一个 class template,而它所提供之“与此 template 相关的”函数支持“所有参数隐式类型转换”时,请将那些函数定义为 “class template 内部的 friend 函数”。

条款47

请使用traits classes 表现类型信息。

STL主要由容器、迭代器和算法的templates构成,也包含若干工具性templates。当中有一个advance用来将迭代器移动某个给定距离:

1
2
template<typename IterT, typename DistT>
void advance(IterT& iter, DistT d);//d大于零。向前移动,小于零则向后移动

表面上看,仅仅是iterate+=d的动作,可是迭代器有5种。仅仅有random access(随机訪问)迭代器才支持+=操作。其它类型没这么大威力。仅仅有重复++和–才行。这里也回想一下这5种迭代器。

  • input迭代器。它是read only,仅仅能读取它指向的对象,且仅仅能读取一次。它仅仅能向前移动。一次一步。它模仿指向输入文件的阅读指针(read pointer);C++程序中的istream_iterators就是这类的代表。
  • output迭代器,和input迭代器相反。它是write only。它也是仅仅能向前移动,一次一步。且仅仅能涂写一次它指向的对象。它模仿指向输出文件的涂写指针(write pointer);ostream_iterators是这一类代表。
  • forward迭代器。这个迭代器派生自input迭代器,所以有input迭代器的全部功能。而且他能够读写指向的对象一次以上。
  • bidirectional迭代器继承自forward迭代器,它的功能还包含向后移动。STL中的list、set、multiset、map、和multimap迭代器就是这一类迭代器。
  • random access迭代器继承自bidirectional迭代器。它厉害的地方在于能够向前或向后跳跃随意距离,这点相似原始指针,内置指针就能够当做random access迭代器使用。vector、deque和string的迭代器就是这类。

这5中分类。C++标准程序库提供专属卷标结构(tag struct)加以确认:

1
2
3
4
5
struct input_iterator_tag {};  
struct output_iterator_tag {};
struct forward_iterator_tag : public input_iterator_tag {};
struct bidirectional_iterator_tag : public forward_iterator_tag {};
struct random_access_iterator_tag : public bidirectional_iterator_tag {};

在了解了迭代器类型后,我们该去实现advance函数了。实现要高效。对于random access迭代器来说,前进d距离要一步完毕。而其它类型则须要重复前进或后退

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename Iter, typename DistT>
void advance(IteT& iter,DistT d)
{
if(iter is a random access iterator)
iter+=d;
else
{
if(d>=0)
while(d--) ++iter;
else
while(d++) --iter;
}
}

在上面实现中要推断iter是否为random access迭代器。即要知道IterT类型是否为random access类型。这就须要traits,它同意我们在编译期间获取某些类型信息。traits是一种技术,是C++程序猿共同遵守的协议。

这个技术要求之中的一个就是,它对内置类型和自己定义类型表现的一样好。如果接受的指针是const char*,advance也必须能够工作。traits必须能够施行于内置类型。意味着“类型内的嵌套信息”这种东西出局了,因为我们无法将信息嵌套于原始指针内。

所以类型的traits信息必须位于类型自身之外。标准技术是把它放进一个template及其一个或多个特化版本号中。这种templates在STL中有若干个,迭代器的为iterator_traits:

1
2
template<typename IterT>//用来处理迭代器分类
struct iterator_traits;

尽管iterator_traits是个struct,往往称作traits classes。其运作方式是,针对每个类型IterT,在struct iterator_traits内声明某个typedef命名为iterator_category,用来确认IterT的迭代器分类。iterator_traits以两个部分实现上述所言。


它要求用户自己定义的迭代器嵌套一个typedef,名为iterator_category。用来确认是哪个卷标结构(tag struct),比如deque和list

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
template<typename T>
class deque{
public:
class iterator{
public:
typedef random_access_iterator_tag iterator_category;
……
};
……
};

template<typename T>
class list{
public:
class iterator{
public:
typedef bidirectional_iterator_tag iterator_category;
……
};
……
};

template<typename IterT>//IterT的iterator_category就是用来表现IterT说自己是什么
struct iterator_traits{
//typedef typename的使用。见条款42
typedef typename IterT::iterator_category iterator_category;
……
};

这样对用户自己定义类型行得通,可是对指针行不通,指针也是迭代器。可是指针不能嵌套typedef。以下就是iterator_traits的第2部分了。专门用来支持指针。为了支持指针迭代器。iterator_traits特别针对类型提供一个偏特化版本号(partial template specialization)。

1
2
3
4
5
6
template<typename IterT>
struct iterator_traits<IterT*>//针对内置指针
{
typedef random_access_iterator_tag iterator_category;
……
};

如今能够知道实现一个traits class步骤了

  • 确认若干我们希望将来可取得的类型相关信息。对于迭代器来说,就是能够取得其分类。
  • 为该信息选择一个名称。对于迭代器是iterator_category。
  • 提供一个template和一组特化版本号。内含你希望支持的类型和相关信息。

现在能够实现一下advance了:

1
2
3
4
5
6
7
template<typename IterT, typename DistT>
void advance(IterT& iter,DisT d)
{
if(typeid(typename std::iterator_traits<IterT>::iterator_category)==
typeid(std::random_access_iterator_tag))
……
}

尽管逻辑是正确,但并不是是我们想要的。抛开编译问题(条款48),另一个更根本的问题:IterT类型在编译期间获知。所以iterator_traits::iterator_category在编译期间确定。

可是if语句却是在执行期间核定。能够在编译期间完毕的事情推到执行期间,这不仅浪费时间,还造成执行文件膨胀。要在编译期间确定。能够使用重载。重载是在编译期间确定的,编译器会找到最匹配的函数来调用:

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
template<typename IterT, typename DisT>
void doAdvance(IterT& iter, Dist d, std::random_access_iterator_tag)//tag不需要参数名,没必要
{
iter+=d;
}
template<typename IterT, typename DisT>
void doAdvance(IterT& iter, Dist d, std::bidirectional_iterator_tag)
{
if(d>=0)
while(d--) ++iter;
else
while(d++) --iter;
}
template<typename IterT, typename DisT>
void doAdvance(IterT& iter, Dist d, std::input_iterator_tag)
{
if(d<0)
throw std::out_of_range("Negative distance");
while(d++) --iter;
}

template<typename IterT,typename DistT>
void advance(IterT& iter,DistT d)//上层调用,获取类型信息来供编译器选择重载函数
{
doAdvance(iter,d,typename std::iterator_traits<IterT>::iterator_category());
}

由于forward_iterator_tag继承自input_iterator_tag,所以input_iterator_tag版本号的函数能够处理forward迭代器。这是由于public继承是is-a关系。实际上,random和bidirectional也是(is-a)input迭代器,但编译器会选择匹配程度最高的。

总结一下怎样使用traits class

  • 建立一组重载函数或函数模板(比如doAdvance)。彼此间差异仅仅在于各自的traits參数。每个函数实现与之接受的traits信息像匹配。
  • 建立一个控制函数或函数模板(比如advance),调用上面的函数并传递traits class信息。

traits 广泛应用于标准库,包括上述iterator_traits,除了iterator_category,iterator_traits还供应四分迭代器相关信息(value_type指明迭代器所指对象类型,difference_type指明迭代器距离类型,pointer指明对象的原生指针类型,reference指明对象的引用类型。此外还有char_traits用于保存字符类型的相关信息,numeric_limits用于保存数值类型相关信息等等。

TR1导入许多新的traits classes用以提供类型信息,包括is_fundamental<T>(判断T是否为内置类型),is_array<T>(判断T是否为数组类型),is_base_of<T1,T2>(判断T1,T2是否相同,抑或T1是T2的base classes).总计TR1一共为C++添加了50个以上的trait classes.

记住:

  • Traits classes使得“类型相关信息”在编译期可用。它们以templates和“templates特化”完成实现。
  • 整合重载技术后,traits classes有可能在编译期对类型执行if…else测试。

条款48

认识template元编程。

Template metaprogramming(TMP,模板元编程)是编写template-based C++程序,编译的过程。template metaprogramming是用C++写的模板程序,编译器编译出具体化的过程。也就是说,TMP程序执行后,从templates具体化出来C++源码,不再是模板了。

TMP有两个作用,一是它让某些事更容易。例如编写STL容器,使用模板,可是存放任何类型元素。二是将执行在运行期的某些工作转移到了编译期。还有一个结果是使用TMP的C++程序可能在其他方面更高效:较小的可执行文件、较短的运行期、较少的内存需求。但是将运行期的工作转移到了编译期,编译期可能变长了。

再看一下条款47中的advance伪码:

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename Iter, typename DistT>
void advance(IteT& iter,DistT d)
{
if(iter is a random access iterator)
iter+=d;
else
{
if(d>=0)
while(d--) ++iter;
else
while(d++) --iter;
}
}

可以使用typeid让判断iter类型的伪码运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<typename Iter, typename DistT>
void advance(IteT& iter,DistT d)
{
if(typeid(typename std::iterator_traits<IterT>::iterator_category)
==typeid(std::random_access_iterator_tag))
iter+=d;
else
{
if(d>=0)
while(d--) ++iter;
else
while(d++) --iter;
}
}

typeid-based解法效率比traits解法低,因为在此方案中,1.类型测试发生在运行期而不是编译期,2.运行期类型测试代码在(或被连接于)可执行文件中。这个例子可以说明TMP比正常的C++程序更高效,因为traits解法就是TMP。

一些东西在TMP比在正常的C++更容易,advance提供一个好例子。advance的typeid-based实现方式可能导致编译期问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
std::list<int>::iterator iter;
……
advance(iter,10);
void advance(std::list<int>::iterator& iter,int d)
{
if(typeid(typename std::iterator_traits<std::list<int>::iterator>::iterator_category)
==typeid(std::random_access_iterator_tag))
iter+=d;//错误
else
{
if(d>=0)
while(d--) ++iter;
else
while(d++) --iter;
}
}

在+=这个操作符上是错误调用。因为list::iterator不支持+=,它是bidirectional迭代器。我们知道不会执行+=那一行,因为typeid那一行总是不相等;但是编译期要确保所有源码都有效,即使是不会执行的代码。traits-based TMP解法针对不同类型执行不同代码,不会出现上述问题。


TMP已被证明是个图灵完全机器,也就是说它的威力足以计算任何事物。可以使用TMP声明变量、执行循环、编写调用函数……。有时候这会和正常C++对应物看起来很是不同,例如条款 47展示的TMP if-else是由templas和其特化具体表现出来。不过那是汇编语言级的TMP。针对TMP设计的程序库(例如Boost’s MPL,条款55)提供更高级的语法。

为了再次认识下事物在TMP中如何运作,来看下循环。TMP没有真正循环,循环由递归(recursion)完成。TMP递归甚至不是正常的递归,因为TMP递归不涉及递归函数调用,而是涉及递归模板化(recursive template instantiation)。

TMP的起手程序是在编译期计算阶乘。TMP的阶乘运输示范如何通过递归模板具体化实现循环,以及如何在TMP中创建和使用变量

1
2
3
4
5
6
7
8
template<unsigned n>
struct Factorial{
enum {value=n*Factorial<n-1>::value};
};
template<>
struct Factorial<0>{ //特殊情况,Factorial<0>的值是1
enum {value=1};
};

有了这个template metaprogram,只要指涉Factorial::value就可以得到n阶乘值。循环发生在template具体化Factorial内部指涉另一个template具体化Factorial之时。特殊情况的template特化版本Factorial<0>是递归的结束。

每个Factorial template具体化都是一个struct,每个struct都声明一个名字为value的TMP变量,用来保存当前计算所获得的阶乘值。TMP以递归模板具体化取代循环,每个具体化有自己一份value,每个value有其循环内适当值。

用Factorial示范TMP就像用hello world示范编程语言一样。为了领悟TMP之所以值得学习,就要先对它能够达成什么目标有一个比较好的理解。下面举三个例子:

  • 确保量度单位正确。使用TMP就可以确保在编译期所有量度单位的组合都正确。

  • 优化矩阵运算。条款 21曾经提到过某些函数包括operator * 必须返回新对象,在条款44中有一个SquareMatrix。如果这样使用

    1
    2
    3
    4
    typedef SquareMatrix<double,1000> BigMatrix;
    BigMatrix m1,m2,m3,m4,m5;
    ……
    BigMatrix result=m1 * m2 * m3 * m4 * m5;

    上面乘法会产生四个临时性矩阵,乘法还可能产生了4个作用在矩阵元素身上的循环。如果使用高级、与TMP相关的template(即expression templates),就有可能消除那些临时对象并合并循环。所以TMP使用较少内存,执行速度也有提升。

  • 可以生成客户定制之设计模式(custom design pattern)实现品。使用policy-based design之TMP-based技术,有可能产生一些templates用来表述独立的设计项(所谓policies),然后可以任意结合它们,导致模式实现品带着客户定制的行为。

TMP目前还不完全成熟,语法不直观,支持的工具还不充分。但TMP对难以或甚至不可能于运行期实现出来的行为表现能力很吸引人。虽然TMP不会成为主流,但是会成为一些程序员(特别是程序库的开发人员)的主要粮食。

记住:

  • Template metaprogramming(TMP,模板元编程)可将工作由运行期移到编译期,因而得以实现早期错误侦测和更高的执行效率。
  • TMP可被用来生成“基于政策选择组合”(based on combinations of policy choices)的客户定制代码,也可用来避免生成对某些特殊类型并不适合的代码。

条款49

了解new-handler的行为。

在operator new抛出异常以前,会先调用一个客户指定的错误处理函数:new-handler。(这其实并非全部事实,operator new 真正做的事更复杂,见条款51)。为了指定这个“用以处理内存不足”的函数,客户必须调用 set_new_handler,那是声明于 <new> 的标准程序库函数:

1
2
3
4
namespace std{
typedef void (*new_handler)();//*new_handler是个typedef,定义出一个指针指向函数,该函数没有参数也不返回任何东西
new_handler set_new_handler(new_handler p) throw();
}

set_new_handler 是“获得一个 new-handler 并返回一个 new-handler ” 的函数,后面的 throw() 是一份异常明细,表示该函数不抛出任何异常。

set_new_handler 的参数是个指针,指向 operator new 无法分配足够内存时该被调用的函数;其返回值也是个指针,指向set_new_handler被调用前正在执行的那个 new-handler 函数。可以这样使用set_new_handler :

1
2
3
4
5
6
7
8
9
10
11
void outOfMem()	//operator new 无法分配足够内存时该被调用的函数
{
std::cerr<<"Unable to satisfy request for memoryn";
std::abort();
}
int main()
{
std::set_new_handler(outOfMem);
int *pBigDataArray = new int[100000000L];
...
}

如果operator new无法为100000000个整数分配足够空间,outOfMem会被调用,于是程序在发出一个信息之后夭折(abort)。

当operator new无法满足内存申请时,它会不断调用 new-handler 函数,直到找到足够内存。反复调用的代码在条款51讨论。这里先说一下,设计良好的new-handler必须做好以下事情:

  • 让更多内存可被使用:这样可以造成operator new内的下一次内存分配动作可能成功。一个做法是,程序一开始就分配一大块内存,当 new-handler 第一次被调用时将它释放。
  • 安装另一个new-handler:当前的 new-handler 无法取得更多可用内存时,或许它知道另外哪个new-handler有此能力。如果真这样,可用使用 set_new_handler 来替换有能力的那个。
  • 卸除 new-handler:即将null指针传给 set_new_handler,一旦没有安装任何 new-handler,operator new 在内存分配不成功时便抛出异常。
  • 抛出 bad_alloc(或派生自bad_alloc)的异常:这样的异常不会被 operator new 捕捉,因此不会被传播到内存索求处。
  • 不返回:通常 abort 或 exit 。

有时候,我们希望处理内存分配失败的情况和类相关。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class X{
public:
static void outOfMemory();
...
};

class Y{
public:
static void outOfMemory();
...
};

X* p1 = new X;//分配不成功,调用X::outOfMemory
Y* p2 = new Y;//分配不成功,调用Y::outOfMemory

C++并不支持类专属的 new-handler,但是我们自己可以实现这种行为。令每一个类提供自己的 set_new_handler 和 operator new即可。其中 set_new_handler 使客户得以指定类专属的 new-handler,operator new则确保在分配类对象内存的过程中以类专属的 new-handler替换 global new-handler。

假设打算处理 Widget 类 内存分配失败的情况。首先要有一个”当 operator new 无法为Widget 分配足够内存时”调用的函数,所以你需要声明一个类型为 new_handler 的 static 成员,用以指向 Widget 的 new-handler,看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Widget{
public:
static std::new_handler set_new_handler(std::new_handler p) throw();
static void* operator new(std::size_t size) throw(std::bad_alloc);
private:
static std::new_handler currentHandler;
};
std::new_handler Widget::currentHandler = 0; //static成员需要在类外定义

std::new_handler Widget::set_new_handler(std::new_handler p) throw() {
std::new_handler oldHandler = currentHandler; //存储之前的new-handler
currentHandler = p; //设置新的new-handler
reutrn oldHandler; //返回之前的new-handler
}

Widget 的 operator new 做以下事情:

  • 调用标准 set_new_handler,告知 Widget 的错误处理函数。这会将 Widget 的 new-handler 安装为 global new-handler。
  • 调用 global operator new 分配内存。如果失败,global operator new 会调用 Widget 的 new-handler,因为那个函数才刚被安装为 global new-handler。如果 global operator new 最终无法分配足够内存,会抛出一个 bad_alloc 异常。这时 Widget 的operator new 必须恢复原本的 global new-handler,之后再传播该异常。为确保原本的 new-handler 总是能够被重新安装回去,使用资源管理对象防止资源泄漏(见条款13)。
  • 如果 global operator new 分配内存成功,Widget 的 operator new 会返回一个指针,指向分配的内存。Widget 析构函数会管理 global new-handler,它会自动将 Widget’s operator new 被调用前的那个 global new-handler 恢复回来。
  • 附:这里无论分配内存成功或失败,都要把旧的的new-handler用std::set_new_handler恢复(实际上安装也是使用set_new_handler,最终使用的都是std的,自定义的版本只是负责安装和回收),这是因为这个new只是针对Widget的,这件事情发生完,Widget安装的new-handler就应该换回去。

下面以C++代码再阐述一次,将从资源管理类开始,那里只有基础性RAII操作,再构造过程中获得一笔资源,并在析构中释还(见条款13):

1
2
3
4
5
6
7
8
9
10
11
12
13
class NewHandlerHolder{
public:
explicit NewHandlerHolder(std::new_handler nh) : handlere(nh){}//存储旧handler

~NewHandlerHolder() {
std::set_new_handler(handler); //恢复handler
}
private:
std::new_handler handler;
//阻止拷贝
NewHandlerHolder(const NewHandlerHolder&);
NewHandlerHolder& operator=(const NewHandlerHolder&);
};

这使得Widget类的 operator new 函数的实现变得简单:

1
2
3
4
5
6
void* Widget::operator new(std::size_t size) throw(std::bad_alloc) {
//使用std::set_new_handler()安装Widget的new-handler,并返回global new-handler 存储在资源管理类中
//分配内存或抛出异常时,在资源管理类的析构函数中恢复global new-handler
NewHandlerHolder h(std::set_new_handler(currentHandler));
return ::operator new(size);
}

Widget客户应该类似这样使用其new-handling:

1
2
3
4
5
6
7
8
9
void outOfMem();					//函数声明,此函数在 Widget 对象分配失败时被调用

Widget::set_new_handler(outOfMem); //设定 outOfMem 为 Widget 的 new-handling 函数,set_new_handler是静态函数
Widget* pw1 = new Widget; //若内存分配失败,则调用 outOfMem 函数

std::string* ps = new std::string; //内存分配失败则调用 global new-handling(如果有的话)

Widget::set_new_handler(0); //设定 Widget 专属 new-handling 为 null
Widget* pw2 = new Widget; //若内存分配失败则立刻抛出异常,因为 Widget 没有专属 new-handling函数

实现这个方案的代码并不因 class 的不同而不同,因此在其它地方也复用这个代码是个合理的构想。一个简单的方式是建立起一个“mixin” 风格的基类,这种基类用来允许派生类继承单一特定能力——在本例中是“设定类专属的 new-handler 能力”。然后将这个基类转换为模板,如此一来每个派生类将获得实体互异的 class data 复件。

这个基类让其派生类继承它获取 set_new_handler和 operator new函数,而模板部分确保每一个派生类获得一个实体互异的currentHandler 成员变量。实现代码和前一个版本的近似,唯一真正意义上不同的是,它现在可被任何有所需要的类使用:

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
template<typename T>
class NewHandlerSupport{ // "mixin" 风格的基类,用以支持类专属的set_new_handler
public:
static std::new_handler set_new_handler(std::new_handler p) throw();
static void* operator new(std::size_t size) throw(std::bad_alloc);
... //其它的 operator new 版本,见条款52
private:
static std::new_handler currentHandler;
};

template<typename T>
std::new_handler NewHandlerSupport<T>::set_new_handler(std::new_handler p) throw() {
std::new_handler oldHandler = currentHandler;
currentHandler = p;
return oldHandler;
}

template<typename T>
void* NewHandlerSupport<T>::operator new(std::size_t size) throw(std::bad_alloc) {
NewHandlerHolder h(std::set_new_handler(currentHandler);
return ::operator new(size);
}
//以下将每一个实体互异的 currentHandler 初始化为null
template<typename T>
std::new_handler NewHandlerSupport<T>::currentHandler = 0;//静态变量初始化

有了这个 类模板,为 Widget 添加 set_new_handler支持能力就容易了:只要令 Widget 继承自 NewHandlerSupport<Widget> 就好,像下面这样:

1
2
3
class Widget:public NewHandlerSupport<Widget>{
//和先前一样,但不必声明 set_new_handler 和 operator new 函数
};

NewHandlerSupport 模板中,从未使用到 类型T,这是为什么呢?

实际上 T 的确不需被使用。我们只希望继承 NewHandlerSupport 的每一个 类 拥有自己的 NewHandlerSupport 复件(其 static 成员变量 currentHandler ),类型参数 T 是用来区分不同的 派生类,模板机制会自动为每一个 T 生成一份 currentHandler 成员变量。

虽然通过继承 NewHandlerSupport ,使得“为任何类添加一个它们专属的new-handler”成为一件很容易的事,但 “mixin” 风格的继承肯定导致 多重继承 的争议,要注意条款40所提到的内容。


C++中新一代的 operator new 分配失败抛出异常 bad_alloc,但是旧标准是返回 null 指针,为了兼容以前使用旧标准的C++程序,C++委员会提供了另一种符合旧标准形式的 operator new , 这个形式被称为 “nothrow” 形式:

1
2
3
4
5
6
7
class Widget{ ... };

Widget* pw1 = new Widget; //分配失败,抛出bad_alloc
if(pw1 == null) { ... } //判断是否分配成功。但是这个测试一定失败

Widget* pw2 = new (std::nothrow)Widget; //分配失败,返回null
if(pw2 == null) { ... } //这个测试可能成功

nothrow new 对 异常的强制保证性(见条款29)并不高。表达式 new (std::nothrow)Widget 会发生两件事:

第一,分配内存给 Widget 对象,如果失败返回 null ;第二,如果成功,调用 Widget 的构造函数,在这个构造函数中可能又 new 一些内存,但没人可以强迫它再次使用 nothrow new。因此,虽然 new (std::nothrow)Widget 调用的 operator new 函数并不抛出异常,但 Widget 的构造函数可能会抛出异常 。

结论是:使用 nothrow new 只能保证 operator new 不抛出异常,不能保证像new (std::nothrow)Widget这样的表达式不抛出异常。所以,并没有使用 nothrow 的需要。

无论使用正常(会抛出异常)的 new,或是不抛出异常的 nothrow new ,重要的是需要了解 new-handler 的行为,因为两种形式都使用到 new-handler。

记住:

  • set_new_handle 允许用户指定一个函数,在内存分配无法获得满足时被调用
  • nothrow new 是一个颇为局限的工具,因为它只适用于内存分配;后继的构造函数调用还是有可能抛出异常

条款50

了解new和delete的合理替换时机。

有人会想要替换掉编译器提供的 operator new 或 operator delete,有三个理由:

  • 用来检测运用上的错误:如果将“new 所得内存” delete 掉却失败,会导致内存泄漏。如果在“new 所得内存”身上多次 delete 会导致不确定行为。使用编译器提供的operator new和operator delete不能检测上述行为。如果 operator new 持有一串动态分配所得地址,而 operator delete 将地址从中移走,这样容易检测出上述错误用法。此外各式各样的编程错误可能导致数据 “overruns”(写入点在分配区块尾端之后)或 “underruns”(写入点在分配区块起点之前)。这时可以自己定义operator new 便可超额分配内存,在额外空间(位于用户所得区块之前或之后)写上特定 byte patterns(即签名,signature),自己定义 operator delete 检 测签名是否更改,若被更改了表示在分配区的某个生命时间点发生了 overrun 或 underrun,这时 operator delete 可以记录(log)那个事实以及那个签名被更改的指针。
  • 为了强化效能:我们所用的编译器中自带的operator new和operator delete主要是用于一般的目的能够为各种类型的程序所接受,而不考虑特定的程序类型。它们必须处理一系列需求,必须接纳各种分配形态,必须要考虑破碎问题等等。因此编译器所带的operator new和operator delete采取中庸之道也是没办法的事情。它们的工作对每个人都是适度地好,但不对特定任何人有最佳表现。通常可以发现,定制版之operator new和operator delete性能胜过缺省版本。所谓的’胜过’,就是它们比较快,有时甚至快很多,而且它们需要内存比较少,最高可省50%。所以说对某些运用程序而言,将缺省new和delete替换为定制版本,是获得重大效能提升的办法之一。
  • 为收集使用上的统计数据:在自定义 operator new 和 operator delete 之前,应该首先了解软件如何使用动态内存。分配区块如何分布?寿命分布如何?它们是先进先出(FIFO)还是后进先出(LIFO)顺序或随机顺序来分配和归还?软件在不同执行阶段有不同的分配归还形态吗?任何时刻使用的最大动态分配量是多少?自定义的 operator new 和 operator delete 可以轻松收集到这些信息。

写个定制的operator new和operator delete并不难。例如,写个global operator new,用于检测在分配区块的后面或前面写入数据。下面是个初步版本,有小错误,后面在完善。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static const int signature = 0xDEADBEEF;
typedef unsigned char Byte;
//下面代码有些小错误
void* operator new(std::size_t size) throw(std::bad_alloc) {
using namespace std;
size_t realSize = size + 2 * sizeof(int); //增加大小,是能够塞入两个 signature

void* pMem = malloc(realSize);
if(!pMem)
throw bad_alloc();

//将 signarure 写入内存的最前段落和最后段落
*(static_cast<int*>(pMem)) = signarure;
//static_cast<Byte*>(pMem)是为了指针做加法运算时是+1个字节
//(指针加法运算+1是一个类型的大小,如int* p加1,实际加了4个字节)
*(reinterpret_cast<int*>(static_cast<Byte*>(pMem) + realSize - sizeof(int))) = signature;

return static_cast<Byte*>(pMem) + sizeof(int); //返回指针,指向恰位于第一个 signarure 之后的内存位置
}

暂且忽略上述代码中没有条款51所说的所有 operator new 都应该内含一个循环,反复调用某个 new-handling 函数。来说一下另外一个主题:对齐。


许多计算机体系结构要求特定的类型必须放在特定的内存地址上。例如它可能要求指针的地址必须是4的倍数(four-byte aligned)或double 的地址是8的倍数(eight-byte aligned)。如果没有这些约束条件可能会导致运行期硬件异常。有些体系结构要求没这么严格,而是宣称如果满足对齐条件,便提供更佳的效率。

C++要求所有 operator new 返回的指针都有适当的对齐(取决于数据类型)。malloc 就是在这样的要求下工作,所以令 operator new返回一个得自 malloc 的指针是安全的。但是上面 operator new 返回一个得自 malloc 且偏移一个 int 大小的指针,没人能保证它的安全。如果用户调用 operator new 企图获取足够给一个 double 所用的内存,而我们在一部 “int 为4位 且 double 必须为 8 位 对齐”的机器中跑程序,我们可能会获得没有对齐的指针,那可能造成程序崩溃或执行速度慢。

像对齐这类技术细节,可以区分内存管理器的质量。写一个能够运行的内存管理器并不难,难的是让它总是能够高效优良的运作。一般来说,若非必要,不要去写内存管理器。

很多时候也是非必要的。有些编译器已经在它们的内存管理函数中切换至调试状态(enable debugging)和志记状态(logging)。许多平台上有商业产品可以代替编译器自带的内存管理器,可以用它们来提高机能和改善效率,你唯一需要做的就是 重新链接。

另外一个选择是开源领域中的内存管理器。它们对许多平台都可以用。Boost程序库(见条款 55)的Pool就是这样的一个分配器,它对常见的分配“大量小型对象”很有帮助。一些小型开源内存分配器大多都不完整,缺少移植、线程安全、对齐等考虑。


本条款的主题是,了解何时可在“全局性的”或“class专属的”基础上合理替换默认的 new 和 delete:

  • 为了检测运用错误(如前所述)
  • 为了手机动态分配内存的使用统计信息(如前所述)
  • 为了增加分配和归还的速度。使用定制的针对特定类型对象的分配器,可以提高效率。类专属分配器是“区块尺寸固定”的分配器实例,例如 Boost 提供的 Pool 程序库便是。如果在单线程程序中,你的编译器所带的内存管理具备线程安全,你可以写个不具备线程安全的分配器而大幅度改善速度。
  • 为了降低默认内存管理器带来的空间额外开销。泛用型内存分配器往往(虽然并非总是)不只比定制型慢,还使用更多内存,因为它们常常在每一个分配区块上招引某些额外开销。针对小型对象开放的分配器(例如 Boost 库的Pool)本质上消除了这样的额外开销。
  • 为了弥补默认分配器中的非最佳对齐。X86体系结构上的 double 访问最快–如果它们是8-byte对齐。但是编译器自带的 operator new 并不保证对动态分配而得的 double 采取8-byte对齐。这种情况下,将默认的 operator new 替换位一个 8-byte 对齐的版本,可使程序效率提升。
  • 为了将相关对象成簇集中。如果特定的某个数据结构往往被一起使用,我们希望在处理这些数据时将“内存页错误”(page faults)的频率降至最低,那么为此数据结构创建另一个 heap 就有意义,这样就可以将它们成簇集中到尽可能少的内存页上。new 和 delete 的 “placement版本”(见条款52)有可能完成这一的集簇行为。
  • 为了获得非传统的行为。有时候我们需要 operator new 和 delete 做编译器附带版没做的某些事情。例如,在归还内存时将其数据覆盖为0,以此增加应用程序的数据安全。

记住:

有许多理由需要写个自定义的 new 和 delete,包括改善效能、对 heap 运用错误进行调试、收集 heap 使用信息。

条款51

编写new和delete时需固守常规。

从operator new开始:

  • 实现一致性 operator new 必须返回正确的值
  • 内存不足时必须调用 new-handling 函数(见条款49)
  • 必须有对付零内存需求的准备
  • 避免不慎掩盖正常形式的 new

这比较偏近 class 接口的要求而非实现要求。正常形式的 new 描述于条款 52。

operator new 的返回值十分单纯。如果申请内存成功,就返回指向那块内存的指针,失败则遵循条款 49描述的规则,并抛出 bad_alloc 异常。

然而也不是非常单纯。因为 operator new 实际上不止一次尝试分配内存,并在每次失败后都调用 new-handling 函数。这里假设 new-handling 函数能做某些动作将一些内存释放出来。只有当指向 new-handling 函数的指针为 null,operator new 才会抛出异常。但C++规定,即使客户要求0 byte,operator new 也要返回一个合法指针。下面是个non-member operator new的伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void* operator new(std::size_t size) throw(std::bad_alloc) {
using namespace std;
if(size == 0) //处理0-byte申请,将它视为 1 byte 申请
size = 1;

while(true) {
尝试分配size bytes;
if(分配成功)
return 指向分配得来的内存的指针;

//分配失败,找到当前的 new-handling 函数
new_handler globalHandler = set_new_handler(0);
set_new_handler(globalHandler);

if(globalHandler)
(*globalHandler)(); //执行函数指针globalHandler指向的函数
else
throw std::bad_alloc();
}
}

对于 0 byte 的内存申请视为 1 byte 的内存申请,做法简单、合法、可行。其中将 new-handling 函数指针设为 null 而后又立刻恢复原样,是因为我们没有任何办法可以直接取得 new-handling 函数指针,所以利用 set_new_handler 函数的返回值是前一个 new-handling 函数指针的特性。这种方法在单线程环境下很有效,但在多线程环境下,还需要某种锁机制,以便处理 new-handling 函数背后的(global)数据结构。

条款49提到 operator new 内含一个无穷循环,而上述代码中的第 6 行(while(true))就是那个无穷循环。退出此循环唯一办法是:内存分配成功或 new-handling 函数做了一件描述于条款49的事:让更多内存可用、安装另一个 new-handler、卸载 new-handler、抛出 bad_alloc异常(或其派生类),或承认失败直接 return。


上面的 operator new 成员函数可能会被derived classes继承。注意分配内存大小size,它是函数接收的实参。条款50提到,定制内存分配器往往是为了特定的 class 对象,以此来优化,而不是为了该 class 的任何派生类。也就是说,针对 class X 而设计的 operator new ,其行为只为大小刚好为 sizeof(X) 的对象而设计。然而一旦被继承,有可能基类的 operator new 被调用用于分配派生类对象:

1
2
3
4
5
6
7
8
class Base{
public:
static void* operator new(std::size_t size) throw(std::bad_alloc);
...
};
class Derived:public Base{ ... }; //假设派生类未定义 operator new

Derived* p = new Derived; //这里调用了Base::operator new

如果基类专属的 operator new 并非被设计用来应对上述情况(实际上往往如此),处理这种情况的方法是:将“内存申请量错误”的调用行为改为标准 operator new,就像这样:

1
2
3
4
5
void* Base::operator new(std::size_t size) throw(std::bad_alloc) {
if(size != sizeof(Base)) //如果大小错误
return ::operator new(size); //使用标准的 operator new
... //否则在这处理
}

不需要再检验size是否为0,C++裁定所有非附属(独立式)对象必须有非零大小(见条款39)。因此sizeof(Base)无论如何不能为零。

如果你打算控制 class 专属的 “arrays 内存分配行为”,那么你需要实现 operator new[](这个函数通常被称为 “array new”)。编写operator new[] 时,唯一要做的事就是分配一块未加工的内存,因为你无法对 array 之内迄今尚未存在的元素对象做任何事。甚至我们无法知道这个 array 含有多少个元素对象。首先你不知道每个对象多大,毕竟 基类 的 operator new[] 有可能经由继承被调用,将内存分配给 “元素为 派生类 对象” 的 array使用。

因此,你不能在 Base::operator new[] 中假设 array 的每个元素对象大小是 sizeof(Base),这样就是说你不能假设 array 元素个数是(bytes申请数 / sizeof(Base))。此外,传递给 operator new[] 的 size_t 参数,其值有可能比“将被填以对象”的内存更大,因为条款 16提过,动态分配的 arrays 可能包含额外空间用来存放元素个数。


上面就是自定义 operator new 需要遵守的规矩。operator delete 情况更简单,你需要记住的唯一事情就是

  • C++保证删除 null 指针永远安全

下面是 non-member operator delete的伪代码:

1
2
3
4
5
void operator delete(void* rawMemory) throw(){
if(rawMemory == 0) return; //如果被删除的是个 null 指针,那就什么都不做

现在, 归还 rawMemory 所指内存;
}

这个函数的 member 版本也很简单,只需多加一个动作——检查删除数量。万一你的 class 专属的 operator new 将大小有误的分配行为转交 ::operator new 执行,你也必须将大小有误的删除行为转交 operator delete 执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Base{
public:
static void* operator new(std::size_t size) throw(std::bad_alloc);
static void operator delete(void* rawMemory,std::size_t size) throw();
...
};

void Base::operator delete(void rawMemory, std::size_t size) throw() {
if(rawMemory == 0) //检测是否为 null 指针
return;
if(size != sizeof(Base)) { //如果大小错误,让标准 operator delete 处理此一申请
::operator delete(rawMemory);
return;
}
现在,归还rawMemory所指内存;
return ;
}

如果即将被删除的对象派生自某个基类 ,而后者没有虚析构函数,那么 C++ 传给 operator delete 的 size_t 数值可能不正确。也就是说,如果基类遗漏虚析构函数,operator delete 可能无法正常运作。

记住:

  • operator new 应该内含一个无穷循环,并在其中尝试分配内存,如果它无法满足内存需求,就该调用 new-handler。它也应该有能力处理 0 bytes 申请。class 专属版本的还应该处理“比正确大小更大的(错误)申请”
  • operator delete 应该在收到 null 指针时不做任何事。class专属版本则还应该处理“比正确大小更大的(错误)申请”

条款52

写了placement new也要写placement delete。

placement new 和 placement delete 在C++中并不常见,如果你不熟悉它们,也不用担心。当你写一个 new 表达式时:

1
Widget* pw = new Widget;

共有两个函数被调用:一个是以分配内存的 operator new,一个是 Widget 的默认构造函数。

假如第一个函数调用成功,但第二个函数却抛出异常,这时需要释放第一步分配得到的内存,否则就造成了内存泄漏。这个时候,用户没有能力去归还内存,因为如果 Widget构造函数抛出异常,那么 pw 尚未被赋值,用户手中的指针还没有指向开辟的内存。因此释放第一步分配得到的内存的任务落到了C++运行期系统身上。

运行期系统会调用第一个函数 operator new 所对应的 operator delete 版本,前提当然是它必须知道哪一个 operator delete(因为可能有多个)版本。如果目前面对的是拥有正常签名式的 new 和 delete,这并不是问题,正常的 operator new 和对应于正常的 operator delete:

1
2
3
void *operator new(std::size_t) throw(std::bad_alloc);	//正常形式
void operator delete(void* rawMemory) throw(); //global 作用域中正常的签名式
void operator delete(void* rawMemory,std::size_t size) throw(); //class 作用域中典型的签名式

如果使用正常的 operator new 和 operator delete,运行期系统可以找到如何释放 new 开辟内存的 delete 函数。但是如果使用非正常形式的 operator new,究竟使用哪个 delete 的问题就出现了。

举个例子,假设编写一个 class 专属的 operator new,要求接受一个 ostream,用来记录(logged)相关分配信息,同时又写了一个正常形式的 class 专属 operator delete:

1
2
3
4
5
6
7
8
class Widget{
public:
...
static void* operator new(std::size_t size, std::ostream& logStream) throw(std::bad_alloc);//非正常形式的new

static void operator delete(void* pMemory, std::size_t size) throw(); //正常的class专属delete
...
};

这个设计有问题,但我们探讨原因之前,需要先绕道,简单讨一些术语。

如果 operator new 接受的参数除了必有的 size_t 之外还有其他,这便是 placement new。因此,上述的 operator new 是个 placement 版本。众多 placement new 版本中,有一个特别有用的是 “接受一个指针指向对象该被构造之处”,vector使用它在未使用的内存上创建对象,那样的 operator new 形式如下:

1
void* operator new(std::size_t, void* pMemory) throw(); //placement new

placement new 有多重定义,一是带任意额外参数的new ,二是只有一个额外参数 void*。当人们谈到 placement new ,大多数是指后者。


现在让我们回到 Widget 的声明式,也就是之前我说设计有问题的那个。这里的难点是,那个类将引起微妙的内存泄漏。看下面的例子,它在动态创建一个 Widget 时将相关的分配信息记录(logs)于 cerr:

1
2
Widget* pw = new (std:cerr) Widget;//调用operator new并传递cerr作为ostream实参,
//这个动作会在Widget构造函数抛出异常时泄漏内存

如果内存分配成功,而 Widget 构造函数抛出异常,运行期系统要释放 operator new 开辟的内存。但运行期系统不知道真正被调用的 operator new 如何运作,因此它无法释放内存。所以上述做法行不通。取而代之的是,运行期系统寻找参数个数和类型都与 operator new 相同的 operator delete,如果找到,那就是它的调用对象。上述代码中调用的 operator new 对应的 operator delete为:

1
void operator delete(void*, std::ostream&) throw();

类似于 new 的 placement 版本,operator delete 如果接收额外参数,便称为 placement delete。上面 Widget 没有 placement 版本的operator delete,所以运行期系统不知道如何释放 operator new 开辟的内存,于是什么都不做。所以,如果 Widget 构造函数抛出异常,不会有任何的 operator delete 被调用。

为了解决上述问题,Widget 有必要声明一个 placement delete,对应那个有记录功能(logging)的 placement new:

1
2
3
4
5
6
7
8
class Widget{
public:
...
static void* operator new(std::size_t size, std::ostream& logStream) throw(std::bad_alloc);
static void operator delete(void* pMemory) throw();
static void operator delete(void* pMemory, std::ostream& logStream) throw();
...
};

这样改变之后,如果以下语句导致Widget 构造函数抛出异常,就不会造成内存泄漏了:

1
Widget* pw = new (std:cerr) Widget;		//这次内存不在泄漏

如果 Widget 构造函数抛出异常,就会调用对应版本的placement delete;如果没有异常,就会调用正常形式的 operator delete,如下:

1
delete pw;//客户调用

需要注意的是:placement delete 只有在 placement new 调用构造函数抛出异常时才会被调用。对着一个指针(例如上述的pw)施行 delete 绝不会导致调用 placement delete。

这意味着:如果要对所有与 placement new 相关的内存泄漏宣战,我们必须同时提供一个正常的 operator delete (用于构造期间无任何异常被抛出)和一个 placement 版本(用于构造期间有异常被抛出), placement 版本的额外参数必须和 operator new 一样。


需要注意的是,因为成员函数的名称会掩盖其外围作用域中相同名称的函数(见条款33),所以要小心避免 class 专属的 new 掩盖用户希望调用的 new。例如,你有一个 Base class,其中声明唯一一个 placement,用户会发现他们无法使用正常形式的 new:

1
2
3
4
5
6
7
8
9
class Base{
public:
...
static void* operator new(std::size_t size, std::ostream& logStream) throw(std::bad_alloc);//这个new会掩盖正常的global new
...
};

Base* pb = new Base; //错误,因为正常形式的operator new被掩盖
Base* pb1 = new (std::cerr) Base; //正确,调用Base的placement new

同样道理,派生类 的 operator new 会掩盖继承而来的 operator new 和 global 版本的 new:

1
2
3
4
5
6
7
8
9
class Derived: public Base{			//继承自先前的Base
public:
...
static void* operator new(std::size_t size) throw(std::bad_alloc);//重新声明正常形式的new
...
};

Derived* pd = new (std::clog) Derived; //错误,因为Base的placement new被掩盖了
Derived* pd1 = new Derived; //正确,调用了 Derived 的 operator new

条款33更详细讨论了这种名称遮掩问题。对于撰写内存分配函数,你需要记住的是,默认情况下C++在 global 作用域内提供以下形式的operator new:

1
2
3
void* operator new(std::size_t) throw(std::bad_alloc);				//normal new
void* operator new(std::size_t, void*) throw(); //placement new
void* operator new(std::size_t, const std::nothrow_t&) throw(); //nothrow new(见条款49)

如果你在 class 内声明任何形式的 operator new ,它都遮掩上述这些标准形式。除非你想要阻止 class 的用户使用这些形式,否则请确保它们在你所生成的任何自定义 operator new 之外还可用。对于每一个可用的 operator new,也要确保提供了对应形式的 operator delete。如果你希望这些函数有着平常的行为,只要令你的 class 专属版本调用 global 版本即可。

完成上面所说的一个简单的做法是,建立一个基类,内含所有正常形式的new和delete

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class StadardNewDeleteForms{
public:
//normal
static void* operator new(std::size_t size) throw(std::bad_alloc){
return ::operator new(size);
}
static void operator delete(void* pMemory) throw(){
::operator delete(pMemory);
}
//placement
static void* operator new(std::size_t size, void* ptr) throw(){
return ::operator new(size, ptr);
}
static void operator delete(void* pMemory, void* ptr) throw(){
::operator delete(pMemory, ptr);
}
//nothrow
static void* operator new(std::size_t size, const std::nothrow_t& nt) throw(){
return ::operator new(size,nt);
}
static void operator delete(void* pMemory,const std::nothrow_t&) throw(){
::operator delete(pMemory);
}
};

如果想以自定义方式扩充标准形式,可以使用继承机制和using声明式(见条款33)取得标准形式:

1
2
3
4
5
6
7
8
9
10
class Widget: public StandardNewDeleteForms{		//继承标准形式
public:
//让这些形式可见
using StandardNewDeleteForms::operator new;
using StandardNewDeleteForms::operator delete;
//添加自己定义的 new/delete
static void* operator new(std::size_t size, std::ostream& logStream) throw(std:;bad_alloc);
static void operator delete(void* pMemory, std::ostream& logStream) throw();
...
};

记住:

  • 当你写一个 placement operator new,请确定也写出了对应的 placement operator delete。如果没有这样做,就可能造成隐蔽的内存泄漏。
  • 当你声明 placement new 和 placement delete ,请确定不要无意识(非故意)地遮掩了它们的正常版本。

条款53

不要轻忽编译器的警告。

许多程序员习惯性的忽略编译器的警告。他们认为,如果问题很严重,那么编译器应该给一个错误而不是警告。这种想法在C++是不可取的,以一个例子来说明:

1
2
3
4
5
6
7
8
class B{
public:
virtual void f() const;
};
class D: public B{
public:
virtual void f();
};

这里希望 D::f()重新定义 虚函数 B::f(),但其中有个错误,B::f() 是个 const成员函数,而 D 不是。编译器不会报错,可能会给如下警告:

1
warning: D::f() hides virtual B::f()

经验不足的程序员认为这是 “D::f 遮掩了 B::f” ,这正是他们想要的。但这是错的,编译器是在告诉你声明于 B 中 的 f 并未在 D 中被重新声明,而是被整个遮掩了(条款33解释为什么会这样)。如果忽略这个警告,几乎肯定导致错误的程序行为,然后为了找出这个编译器已经告诉你的错误而进行许多调试。

1
2
3
4
5
c++多态规定,基类和派生类中同名虚函数的函数名、返回值类型、函数参数个数及参数类型等必须完全相同。

如果基类的虚函数后面没有加const,派生类同名的函数后面加了const,那么派生类的函数没有起到虚函数的作用(前提是除了const外,函数的其他参数一样);同理,如果基类的虚函数后面加了const,而派生类同名函数后面没有加const,派生类的同名函数也没有起到虚函数的作用;如果基类的虚函数后面加了const,派生类同名函数也加了const,那么派生类同名函数起到了虚函数的作用。

c++规定,同一个函数加不加const,经过编译器编译之后是两个不同的函数,所以基类和派生类的同一个函数,后面加不加const,编译后是两个不同的函数,也就不存在多态。

一旦从编译器的警告信息中获得经验,你将学会了解不同的警告信息意味什么,那往往和它们“看起来“的意义并十分不同。一般认为,写出一个在最高警告级别下也没有任何警告信息的程序是最理想的,然而你一旦对警告信息有了深刻理解,可以选择忽略某些警告信息。但是一定记住在忽略这个警告之前,一定要了解它的真正意义。

警告信息和编译器相依,不同的编译器有不同的警告标准。所以,草率编程然后倚赖编译器为你指出错误的行为并不可取。例如上面代码中的函数遮掩在另一个编译器中编译,可能没有任何警告。

记住:

  • 严肃对待编译器发出的警告信息。努力在你的编译器最高警告级别下争取”无任何警告“。
  • 不要过度依赖编译器的报警能力,因为不同编译器对待事情的态度并不相同。一段有警告的代码,移植到另一个编译器上,可能没有任何警告。

条款54

让自己熟悉包括TR1在内的标准程序库。

C++98列入的C++标准 程序库有哪些主要成分:

  • STL(Standard Template Library):覆盖容器、迭代器、算法、函数对象、各种容器适配器和函数对象适配器。
  • Iostreams:覆盖用户自定缓冲功能、国际化I/O,以及预先定义好的对象cin,cout,cerr和clog。
  • 国际化支持:包括多区域能力,像wchar_t和wstring等类型都对促进unicode有所帮助。
  • 数值处理:包括复数模板和纯数值数组。
  • 异常阶层体系:包括base class exception及其derived classes logic_error和runtime_error,以及更深继承的各个classes。
  • C89标准程序库:1989 C标准程序库内的每个东西也都被覆盖于C++内。

TR1详细叙述了14个新组件,统统都放在std命名空间内,更正确地说是在其嵌套命名空间tr1内。因此TR1组件shared_ptr的全名是std::tr1::shared_ptr。本书通常在讨论标准程序库组件时略而不写std::,但我总是会在TR1组件之前加上tr1::。

本书展示以下TR1组件实例:

  • 智能指针tr1::shared_ptr和tr1::weak_ptr。前者的作用有如内置指针,但会记录有多少个tr1::shared_ptrs共同指向同一个对象。所谓的reference counting(引用计数)。一旦最后一个这样的指针被销毁,这个对象被自动删除。但是如果两个或多个这样的指针形成环,这会造成每个对象的引用次数都超过0——即使这个环形的指所有指针都已被销毁。tr1::weak_ptr的设计使其表现像是“非环形tr1::shared_ptr-based数据结构”中的环形感生指针(cycle-including pointers)。tr1::weak_ptr并不参与引用计数的计算;当最后一个指向某对象的tr1::shared_ptr被销毁,纵使还有个tr1::weak_ptrs继续指向同一对象,该对象仍旧会被删除。这种情况下的tr1::weak_ptr会被自动标示无效。

  • tr1::function:此物得以表示任何callable entity(可调用物,也就是任何函数或函数对象),只要其签名符合目标。假设我们想注册一个callback函数,该函数接受一个int并返回一个string,我们可以这么写:

    1
    void registerCallback(string func(int)); //参数类型是函数,该函数接受一个int并返回一个string

    其中参数名称func可有可无,所以上述的registerCallback也可以这样声明:

    1
    void registerCallback(string (int));

    tr1::function使上述的RegisterCallback有可能更富弹性地接受可调用物,只要这个可调用物接受一个int或任何可转换为int的东西,并返回一个string或任何可被转换为string的东西。tr1::function是个template,以其目标函数的签名为参数:

    1
    void registerCallback(tr1::function<string (int)>func);
  • tr1::bind:它能够做STL绑定器bindlst和bind2nd所作的每一件事,而又更多。

其他TR1组件划分为两组。第一组提供彼此不相干的独立机能:

  • Hash tables:用来实现sets,multisets,maps和multi-maps。每个新容器的接口都以前任(TR1之前的)对应容器塑膜而成。
  • 正则表达式:包括以正则表达式为基础的字符串查找和替换,或是从某个匹配字符串到另一个匹配字符串的逐一迭代等等。
  • Tuples:这是标准程序库中的pair_template的新一代制品。pair只能持有两个对象,tr1::tuple可持有任意个数的对象。
  • tr1::array:本质上是个“STL化”数组,即一个支持成员函数如begin和end的数组,不过tr1::array的大小固定,并不适用动态内存。
  • tr1::mem_fn:这是个语句构造上与成员函数指针一致的东西。tr1::mem_fn纳入并扩充了C++98的men_fun和mem_fun_ref的能力。
  • tr1::reference_wrapper:一个“让references的行为更像对象”的设施。它可以造成容器“犹如持有references”。而容器实际上只能持有对象或指针。
  • 随机数生成工具:它大大超越了rand,那是C++继承自C标准程序库的一个函数。
  • 数学特殊函数:包括Lagnuerre多项式、Bessel函数、完全椭圆积分,以及更多数学函数。
  • C99兼容扩充:这是一大堆函数和模板,用来将许多新的C99程序库特性带进C++。

第二组TR1组件由更精巧的template编程技术(包括template metaprogramming)构成:

  • Type traits:一组traits class,用以提供类型的编译期信息。(见条款47)
  • tr1::result_of:这是个template,用来推导函数调用的返回类型。当我们编写template时,能够“指涉函数调用动作所返回的对象的类型”往往很重要,但是该类型有可能以复杂的方式取决于函数的参数类型。tr1::result_of使得“指涉函数返回类型”变得十分容易。

记住:

  • C++标准程序库的主要机能由STL、iostreams、locales组成。并包含C99标准程序库。
  • TR1添加了智能指针、一般化函数指针、hash-based容器、正则表达式以及另外10个组件的支持。
  • TR1自身只是一份规范,为获得TR1提供的好处,你需要一份实物。一个好的实物来源是Boost。

条款55

让自己熟悉Boost。

你正在寻找一个高质量,源码开放,平台独立,编译器独立的程序库吗?看看 Boost 吧。有兴趣加入一个由雄心勃勃,充满才干的 C++ 开发人员组成的社群,致力发展当前最高技术水平的程序库吗?看看 Boost 吧。想要一瞥未来 C++ 的面目吗?看看 Boost 吧。

Boost网址:Boost C++ Libraries

Boost有两点被认为是其它组织所不能比拟的:

  • Boost 委员会和 C++ 标准委员会成员有很密切的关系,并对其有着深刻的影响能力。Boost 由标准委员会创立,因此两者成员有着很大重叠。Boost 有个目标:作为一个可被加入到标准 C++ 的功能测试场。这层关系造就的结果是:以 TR1 为例,进入标准 C++ 的 14个新程序库中,超过三分之二奠基于 Boost 的工作成果。
  • Boost 接纳新程序库的过程也很有意思。它以公开进行的同僚复审为基础。如果你有意贡献一个程序库给 Boost,首先要对 Boost 开发者邮递开发作品,在评价这个程序库的重要性之后,启动初步审查程序。
    • 当你最终正式提交时,你要满足一些最低条件。例如它必须通过至少两个编译器,以展示至此微不足道的可移植性,你必须证明你的程序库是在一个可接受的授权许可下是可用的,例如必须免费商业化和非商业化用途…
    • 进入复审阶段,会有志愿者查看你的程序库和各种素材,例如源码,设计文档,使用说明等,并考虑以下问题:
      • 这份设计和实现有多么好?
      • 这些代码可跨编译器和操作系统吗?
      • 这个程序库有可能被它所设定的用户使用吗?
    • 这些对于阻挡低劣的程序库很有帮助,并且启发程序库作者认真考虑一个工业强度,跨平台的程序库设计,实现和文档工程

Boost 程序库涉及的领域很多:

  • 字符串和文本处理:包括类型安全(type-safe) 的形如printf的格式化库,正则表达式(TR1中相似的功能的基础,看条款54),tokenizing 和 parsing 。

  • 容器:包括STL风格接口的大小固定的数组(fixed-size arrays),可变大小bitsets,和多维数组(multidimensional arrays.)。

  • 函数对象(Function objects)和高阶编程(higher-order programming):包括几个TR1中作为功能性基础使用的库。一个有趣的库是 Lambda, 它使得凭空创建一个函数对象(function objects)如此容易,你甚至不需要知道到你做了什么:

    1
    2
    3
    4
    5
    6
    7
    using namespace boost::lambda;    //使 boost::lambda可见
    std::vector<int> v;
    ...
    std::for_each(v.begin(), v.end(), //遍历v中的元素x,
    std::cout << _1 * 2 + 10 << "n"); //输出 x*2+10;
    // "_1"是Lambda
    //为当前元素设置的置位符
  • 泛型编程(Generic programming):包括一个traits类的扩展集(看条款 47关于traits 的资料)。

  • 模板元编程(Template metaprogramming TMP 看条款 48):包括一个Boost MPL 这样的编译期断言库(compile-time assertions)。在MPL极好事情之一是支持STL风格的形如类型(types)的编译期实体的数据结构。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    //创建一个形如list(list-like)的编译期容器,容器包括3种数据类型(float, double 和 long double),并命名为"floats"。
    typedef boost::mpl::list<float, double, long double> floats;
    //创建一个新的编译期的由"floats"及在其前端插入的"int"所组成类型的list;并命名新的容器为"types"。
    typedef boost::mpl::push_front<floats, int>::type types;

    /*
    这样的“类型容器”(经常被称为typelists,虽然他们也基于mpl::list和mpl::vector创建)
    打开了通向强大且重要的TMP应用的广阔天地。
    */
  • 数学和数值(Math and numerics):包括有理数库(rational numbers);octonions和四元数(quaternions);最大公约数(greatest common divisor)和最小公倍数(common multiple computations);随机数(另一个影响TR1相关功能的库)。

  • 正确性和测试(Correctness and testing):包括形式化隐式模板接口(formalizing implicit template interfaces (阅读条款 41)) 方便测试优先(test-first) 编程。

  • 数据结构(Data structures):包括类型安全的unions库(例:存储变量的”any”类型)和导致相应TR1功能的tuple库。

  • 交互语言支持(Inter-language support):包括充许在C++和Python之间进行无缝协作的库。

  • 内存:包括高性能的固定大小分配的Pool库;多样的智能指针,包括(但不限于)在TR1中的智能指针。非TR1智能指针是scoped array, 为动态分配数组的auto_ptr风格的智能指针; 条款44展示一个使用的例子。

请记住,这只是一份抽样,并不是一份详尽清单。

Boost 提供的程序库可做的事情有很多,但它并未覆盖编程的所有领域,不过纵使你没能找到刚好符合需求的作品,也一定会在其中发现一些有趣的东西

记住:

  • Boost 是一个社群,也是一个网站。致力于免费,源码开放,同僚复审的 C++ 程序库开发。 Boost 在 C++ 标准化过程中扮演深具影响力的角色。
  • Boost 提供许多 TR1 组件实现品,以及其他许多程序库。

lambda 表达式是 C++11 最重要也最常用的一个特性之一,C#3.5 和 Java8 中就引入了 lambda 表达式。

lambda 来源于函数式编程的概念,也是现代编程语言的一个特点。C++11 这次终于把 lambda 加进来了。

lambda表达式有如下优点:

  • 声明式编程风格:就地匿名定义目标函数或函数对象,不需要额外写一个命名函数或者函数对象。以更直接的方式去写程序,好的可读性和可维护性。
  • 简洁:不需要额外再写一个函数或者函数对象,避免了代码膨胀和功能分散,让开发者更加集中精力在手边的问题,同时也获取了更高的生产率。
  • 在需要的时间和地点实现功能闭包,使程序更灵活。

lambda 表达式的概念和基本用法

lambda 表达式定义了一个匿名函数,并且可以捕获一定范围内的变量。lambda 表达式的语法形式可简单归纳如下:

1
2
//其中 capture 是捕获列表,params 是参数表,opt 是函数选项,ret 是返回值类型,body是函数体。
[ capture ] ( params ) opt -> ret { body; };

因此,一个完整的 lambda 表达式看起来像这样:

1
2
auto f = [](int a) -> int { return a + 1; };
std::cout << f(1) << std::endl; // 输出: 2

可以看到,上面通过一行代码定义了一个小小的功能闭包,用来将输入加 1 并返回。

在 C++11 中,lambda 表达式的返回值是通过前面介绍的《C++返回值类型后置》语法来定义的。其实很多时候,lambda 表达式的返回值是非常明显的,比如这个例子。因此,C++11 中允许省略 lambda 表达式的返回值定义:

1
auto f = [](int a){ return a + 1; };

这样编译器就会根据 return 语句自动推导出返回值类型。

需要注意的是,初始化列表不能用于返回值的自动推导:

1
2
auto x1 = [](int i){ return i; };  // OK: return type is int
auto x2 = [](){ return { 1, 2 }; }; // error: 无法推导出返回值类型

这时我们需要显式给出具体的返回值类型。

另外,lambda 表达式在没有参数列表时,参数列表是可以省略的。因此像下面的写法都是正确的:

1
2
auto f1 = [](){ return 1; };
auto f2 = []{ return 1; }; // 省略空参数表

使用 lambda 表达式捕获列表

lambda 表达式还可以通过捕获列表捕获一定范围内的变量:

  • [] 不捕获任何变量。
  • [&] 捕获外部作用域中所有变量,并作为引用在函数体中使用(按引用捕获)。
  • [=] 捕获外部作用域中所有变量,并作为副本在函数体中使用(按值捕获,无法修改)。
  • [=,&foo] 按值捕获外部作用域中所有变量,并按引用捕获 foo 变量。
  • [bar] 按值捕获 bar 变量,同时不捕获其他变量。
  • [this] 捕获当前类中的 this 指针,让 lambda 表达式拥有和当前类成员函数同样的访问权限。如果已经使用了 & 或者 =,就默认添加此选项。捕获 this 的目的是可以在 lamda 中使用当前类的成员函数和成员变量。

下面看一下它的具体用法,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class A
{
public:
int i_ = 0;
void func(int x, int y)
{
auto x1 = []{ return i_; }; // error,没有捕获外部变量
auto x2 = [=]{ return i_ + x + y; }; // OK,捕获所有外部变量
auto x3 = [&]{ return i_ + x + y; }; // OK,捕获所有外部变量
auto x4 = [this]{ return i_; }; // OK,捕获this指针
auto x5 = [this]{ return i_ + x + y; }; // error,没有捕获x、y
auto x6 = [this, x, y]{ return i_ + x + y; }; // OK,捕获this指针、x、y
auto x7 = [this]{ return i_++; }; // OK,捕获this指针,并修改成员的值
}
};
int a = 0, b = 1;
auto f1 = []{ return a; }; // error,没有捕获外部变量
auto f2 = [&]{ return a++; }; // OK,捕获所有外部变量,并对a执行自加运算
auto f3 = [=]{ return a; }; // OK,捕获所有外部变量,并返回a
auto f4 = [=]{ return a++; }; // error,a是以复制方式捕获的,无法修改
auto f5 = [a]{ return a + b; }; // error,没有捕获变量b
auto f6 = [a, &b]{ return a + (b++); }; // OK,捕获a和b的引用,并对b做自加运算
auto f7 = [=, &b]{ return a + (b++); }; // OK,捕获所有外部变量和b的引用,并对b做自加运算

从上例中可以看到,lambda 表达式的捕获列表精细地控制了 lambda 表达式能够访问的外部变量,以及如何访问这些变量。

需要注意的是,默认状态下 lambda 表达式无法修改通过复制方式捕获的外部变量。如果希望修改这些变量的话,我们需要使用引用方式进行捕获。


一个容易出错的细节是关于 lambda 表达式的延迟调用

1
2
3
4
int a = 0;
auto f = [=]{ return a; }; // 按值捕获外部变量
a += 1; // a被修改了
std::cout << f() << std::endl; // 输出?

在这个例子中,lambda 表达式按值捕获了所有外部变量。在捕获的一瞬间,a 的值就已经被复制到f中了。之后 a 被修改,但此时 f 中存储的 a 仍然还是捕获时的值,因此,最终输出结果是 0。

如果希望 lambda 表达式在调用时能够即时访问外部变量,我们应当使用引用方式捕获。

从上面的例子中我们知道,按值捕获得到的外部变量值是在 lambda 表达式定义时的值。此时所有外部变量均被复制了一份存储在 lambda 表达式变量中。虽然修改 lambda 表达式中的这些外部变量并不会真正影响到外部,我们却仍然无法修改它们

那么如果希望去修改按值捕获的外部变量应当怎么办呢?这时,需要显式指明 lambda 表达式为 mutable:

1
2
3
int a = 0;
auto f1 = [=]{ return a++; }; // error,修改按值捕获的外部变量
auto f2 = [=]() mutable { return a++; }; // OK,mutable

需要注意的一点是,被 mutable 修饰的 lambda 表达式就算没有参数也要写明参数列表

lambda 表达式的类型

最后,介绍一下 lambda 表达式的类型。

lambda 表达式的类型在 C++11 中被称为“闭包类型(Closure Type)”。它是一个特殊的,匿名的非 nunion 的类类型。

因此,我们可以认为它是一个带有 operator() 的类,即仿函数。因此,我们可以使用 std::functionstd::bind 来存储和操作 lambda 表达式:

1
2
std::function<int(int)>  f1 = [](int a){ return a; };
std::function<int(void)> f2 = std::bind([](int a){ return a; }, 123);

关于std::function和std::bind,在后面章节介绍。

另外,对于没有捕获任何变量的 lambda 表达式,还可以被转换成一个普通的函数指针:

1
2
3
using func_t = int(*)(int);
func_t f = [](int a){ return a; };
f(123);

lambda 表达式可以说是就地定义仿函数闭包的“语法糖”。它的捕获列表捕获住的任何外部变量,最终均会变为闭包类型的成员变量。而一个使用了成员变量的类的 operator(),如果能直接被转换为普通的函数指针,那么 lambda 表达式本身的 this 指针就丢失掉了。而没有捕获任何外部变量的 lambda 表达式则不存在这个问题。

这里也可以很自然地解释为何按值捕获无法修改捕获的外部变量。因为按照 C++ 标准,lambda 表达式的 operator() 默认是 const 的。一个 const 成员函数是无法修改成员变量的值的。而 mutable 的作用,就在于取消 operator() 的 const。

需要注意的是,没有捕获变量的 lambda 表达式可以直接转换为函数指针,而捕获变量的 lambda 表达式则不能转换为函数指针。看看下面的代码:

1
2
3
typedef void(*Ptr)(int*);
Ptr p = [](int* p){delete p;}; // 正确,没有状态的lambda(没有捕获)的lambda表达式可以直接转换为函数指针
Ptr p1 = [&](int* p){delete p;}; // 错误,有状态的lambda不能直接转换为函数指针

上面第二行代码能编译通过,而第三行代码不能编译通过,因为第三行的代码捕获了变量,不能直接转换为函数指针。

声明式的编程风格,简洁的代码

就地定义匿名函数,不再需要定义函数对象,大大简化了标准库算法的调用。比如,在 C++11 之前,我们要调用 for_each 函数将 vector 中的偶数打印出来,如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class CountEven //一个仿函数
{
int& count_;
public:
CountEven(int& count) : count_(count) {}
void operator()(int val)
{
if (!(val & 1)) // val % 2 == 0
{
++ count_;
}
}
};
std::vector<int> v = { 1, 2, 3, 4, 5, 6 };
int even_count = 0;
for_each(v.begin(), v.end(), CountEven(even_count));
std::cout << "The number of even is " << even_count << std::endl;

这样写既烦琐又容易出错。有了 lambda 表达式以后,我们可以使用真正的闭包概念来替换掉这里的仿函数,代码如下:

1
2
3
4
5
6
7
8
9
10
std::vector<int> v = { 1, 2, 3, 4, 5, 6 };
int even_count = 0;
for_each( v.begin(), v.end(), [&even_count](int val)
{
if (!(val & 1)) // val % 2 == 0
{
++ even_count;
}
});
std::cout << "The number of even is " << even_count << std::endl;

lambda 表达式的价值在于,就地封装短小的功能闭包,可以极其方便地表达出我们希望执行的具体操作,并让上下文结合得更加紧密。

std::function

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
// std::function,其中Rp是返回类型,ArgTypes是参数类型
template<class _Rp, class ..._ArgTypes>
class _LIBCPP_TEMPLATE_VIS function<_Rp(_ArgTypes...)>
: public __function::__maybe_derive_from_unary_function<_Rp(_ArgTypes...)>,
public __function::__maybe_derive_from_binary_function<_Rp(_ArgTypes...)>
{ ... }

//使用:

//1.std::function对象实例包装函数指针
std::function<int(int)> callback;//定义一个std::function<int(int)>对象实例

int (*fun_ptr)(int);

int fun1(int a){
return a;
}

int main(int argc, char *argv[]){
std::cout << "Hello world" << std::endl;

fun_ptr = fun1; //函数指针fun_ptr指向fun1函数
callback = fun_ptr; //std::function对象包装函数指针
std::cout << callback(10) << std::endl; //std::function对象实例调用包装的实体

return 0;
}

//2.std::function包装函数
int fun1(int a){
return a;
}

int main(int argc, char *argv[]){
std::cout << "Hello world" << std::endl;

callback = fun1; //std::function包装函数
std::cout << callback(42) << std::endl; //std::function对象实例调用包装的调用实体

return 0;
}

//3.std::function包装模板函数
template<typename T>
T fun2(T a){
return a + 2;
}

int main(int argc, char *argv[]){
std::cout << "Hello world" << std::endl;

callback = fun2<int>; //std::function包装模板函数
std::cout << callback(10) << std::endl; //std::function对象实例调用包装的调用实体

return 0;
}

//4.std::function包装函数对象
struct add{
int operator()(int x){
return x + 9;
}
};

int main(int argc, char *argv[]){
std::cout << "Hello world" << std::endl;

callback = add(); //std::function包装对象函数
std::cout << callback(2) << std::endl; //std::function对象实例调用包装的调用实体

return 0;
}

//5.std::function包装lamda表达式
int main(int argc, char *argv[]){
std::cout << "Hello world" << std::endl;

auto fun3 = [](int a) {return a * 2;}; //lamda表达式
callback = fun3; //std::function包装lamda表达式
std::cout << callback(9) << std::endl; //std::function对象实例调用包装的调用实体

return 0;
}

std::bind

C++11中提供了std::bind。bind()函数的意义就像它的函数名一样,是用来绑定函数调用的某些参数的。

bind的思想实际上是一种延迟计算的思想,将可调用对象保存起来,然后在需要的时候再调用。而且这种绑定是非常灵活的,不论是普通函数、函数对象、还是成员函数都可以绑定,而且其参数可以支持占位符,比如你可以这样绑定一个二元函数auto f = bind(&func, _1, _2);,调用的时候通过f(1,2)实现调用。

简单的认为就是std::bind就是std::bind1ststd::bind2nd的加强版。


使用(与this指针相关):

std::function可以绑定全局函数,静态函数,但是绑定类的成员函数时,需要借助std::bind的帮忙。但是话又说回来,不借助std::bind也是可以完成的,只需要传一个*this变量进去就好了,比如:

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
 
#include <iostream>
#include <functional>
using namespace std;

class View
{
public:
void onClick(int x, int y)
{
cout << "X : " << x << ", Y : " << y << endl;
}
};

// 定义function类型, 三个参数
function<void(View, int, int)> clickCallback;

int main(int argc, const char * argv[])
{
View button;

// 指向成员函数
clickCallback = &View::onClick;

// 进行调用方法1
clickCallback(button, 10, 123);//成员函数第一个参数是this指针,这里把this指针传进去

// 进行调用方法2.使用bind,第一个参数绑定成员函数,第二个参数为this指针
auto bindFunc1 = bind(&View::onClick,this,std::placeholders::_1,std::placeholders::_2);
bindFunc(10,123)
return 0;
}

其他使用std::bind代码的例子:

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>
#include <functional>
using namespace std;

int TestFunc(int a, char c, float f)
{
cout << a << endl;
cout << c << endl;
cout << f << endl;

return a;
}

int main()
{
auto bindFunc1 = bind(TestFunc, std::placeholders::_1, 'A', 100.1);
bindFunc1(10);

cout << "=================================\n";

auto bindFunc2 = bind(TestFunc, std::placeholders::_2, std::placeholders::_1, 100.1);
bindFunc2('B', 10);

cout << "=================================\n";

auto bindFunc3 = bind(TestFunc, std::placeholders::_2, std::placeholders::_3, std::placeholders::_1);
bindFunc3(100.1, 30, 'C');

return 0;
}

上面这段代码主要说的是bind中std::placeholders的使用。 std::placeholders是一个占位符。当使用bind生成一个新的可调用对象时,std::placeholders表示新的可调用对象的第几个参数和原函数的第几个参数进行匹配

以下是使用std::bind的一些需要注意的地方:

  • bind预先绑定的参数需要传具体的变量或值进去,对于预先绑定的参数,是pass-by-value的;
  • 对于不事先绑定的参数,需要传std::placeholders进去,从_1开始,依次递增。placeholder是pass-by-reference的;
  • bind的返回值是可调用实体,可以直接赋给std::function对象;
  • 对于绑定的指针、引用类型的参数,使用者需要保证在可调用实体调用之前,这些参数是可用的;
  • 类的this可以通过对象或者指针来绑定。

当我们厌倦了使用std::bind1ststd::bind2nd的时候,现在有了std::bind,你完全可以放弃使用std::bind1ststd::bind2nd了。std::bind绑定的参数的个数不受限制,绑定的具体哪些参数也不受限制,由用户指定,这个bind才是真正意义上的绑定。

首先提出解决方案:

  • atoi(头文件<stdlib.h>):
    • int atoi(const char *str)该函数返回转换后的长整数,如果没有执行有效的转换,则返回零。
  • strtol(头文件<stdlib.h>):
    • long int strtol(const char *str, char **endptr, int base)该函数返回转换后的长整数,如果没有执行有效的转换,则返回一个零值。
    • str – 要转换为长整数的字符串。
    • endptr – 对类型为 char* 的对象的引用,其值由函数设置为 str 中数值后的下一个字符。(即把整数部分后面的字符串返回)
    • base – 基数,必须介于 2 和 36(包含)之间,或者是特殊值 0。
  • stoi(头文件<string>):
    • int stoi (const string& str, size_t* idx = 0, int base = 10);

这几个有什么不同呢?下面测试对比。

C语言风格函数

atoi与strtol对比:

1
2
3
string str = "16s";
int a = atoi(str.c_str());
int b = strtol(str.c_str(), nullptr, 10);

输出:

1
2
atoi的结果为:16
strtol的结果为:16

这两个函数都是从字符串开始寻找数字或者正负号或者小数点,遇到非法字符终止(即匹配第一个出现的连续的数字)。

所以到上述s字符就不输出了,提前结束,也就是说当你的字符串不是数字的时候,或者小数点等非数字,不会报异常!直接输出0

例如:

1
2
3
string str = "asdsa";
int a = atoi(str.c_str());
int b = strtol(str.c_str(), nullptr, 10);

输出:

1
2
0
0

strtol相比与atoi来说,支持多种进制转换,例如8进制等

例如:

1
int b = strtol(str.c_str(), nullptr, 8);

C++风格

在C++中可以使用stoi来转int,这个函数相比于前两个一个最大特点是:异常

我们知道C++相比于C语言多了异常,这也是这个函数在C++中具有的最显著功能。

例如:

1
2
3
4
5
string str1 = "asq,";
// int c = stoi(str1); // 报异常
string str2 = "12312";
int c = stoi(str2); // ok
cout << c << endl;

异常如下:

1
2
terminate called after throwing an instance of 'std::invalid_argument'
what(): stoi
  • stoi()会检查输入是否越界,默认范围也是在int的范围内,越界后则会报错runtime error!
  • atoi()没有安全性检查
    如果我们输入的这个字符串转换成int超出了int范围[-2147483648, 2147483647],则会输出错误
    如果结果超出了int的上界,则输出为上界的值2147483647
    如果结果超出了下界,则输出为下界的值-2147483647
    如果字符串无法转换为一个int或这个字符串为空,则会返回0。

自定义,更推荐这种方法

也就是自己写,如下:

1
2
3
4
5
6
7
8
9
10
11
int stringToInt(const string &s) {
int v;
stringstream ss;
ss << s;
ss >> v;
return v;
}
int main() {
int i = stringToInt("2.3");
cout<<i<<endl;
}

这里介绍一下stringstream

<sstream> 定义了三个istringstreamostringstreamstringstream,分别用来进行流的输入、输出和输入输出操作。

<sstream> 主要用来进行数据类型转换,由于 <sstream> 使用 string 对象来代替字符数组(snprintf 方式),避免了缓冲区溢出的危险;而且,因为传入参数和目标对象的类型会被自动推导出来,所以不存在错误的格式化符号的问题。简单说,相比 C 编程语言库的数据类型转换,<sstream> 更加安全、自动和直接。

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
// int 转 string
#include <string>
#include <sstream>
#include <iostream>
#include <stdio.h>

using namespace std;

int main()
{
stringstream sstream;
string strResult;
int nValue = 1000;

// 将int类型的值放入输入流中
sstream << nValue;
// 从sstream中抽取前面插入的int类型的值,赋给string类型
sstream >> strResult;

cout << "[cout]strResult is: " << strResult << endl;
printf("[printf]strResult is: %s\n", strResult.c_str());

return 0;
}

//转任意类型
template<typename out_type, typename in_value>
out_type convert(const in_value & t){
stringstream stream;
   stream<<t;//向流中传值
   out_type result;//这里存储转换结果
   stream>>result;//向result中写入值
   return result;
    }

导入

先看一组类:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
#include <iostream>
#include <mutex>
#include <fstream>
using namespace std;

enum class shape_type {//枚举类
circle,
triangle,
rectangle,
};

class shape {
public:
shape() { cout << "shape" << endl; }

virtual void print() {
cout << "I am shape" << endl;
}

virtual ~shape() {}
};

//-------------------子类-------------------------------------
class circle : public shape {
public:
circle() { cout << "circle" << endl; }

void print() {
cout << "I am circle" << endl;
}
};

class triangle : public shape {
public:
triangle() { cout << "triangle" << endl; }

void print() {
cout << "I am triangle" << endl;
}
};

class rectangle : public shape {
public:
rectangle() { cout << "rectangle" << endl; }

void print() {
cout << "I am rectangle" << endl;
}
};
//-------------------子类-------------------------------------

// 利用多态 上转 如果返回值为shape,会存在对象切片问题(强制转换,损失数据)。
// 使用基类指针指向派生类
shape *create_shape(shape_type type) {
switch (type) {
case shape_type::circle:
return new circle();
case shape_type::triangle:
return new triangle();
case shape_type::rectangle:
return new rectangle();
}
}

class shape_wrapper {
public:
explicit shape_wrapper(
shape* ptr = nullptr)
: ptr_(ptr) {}
~shape_wrapper()
{
delete ptr_;
}
shape* get() const { return ptr_; }
private:
shape* ptr_;
};

shape_wrapper这个类可以完成智能指针的最基本的功能:对超出作用域的对象进行释放。但它缺了点东西:

  • 这个类只适用于 shape 类
  • 该类对象的行为不够像指针
  • 拷贝该类对象会引发程序行为

手写auto_ptr与scope_ptr

针对”这个类只适用于 shape 类”,我们想到了模板,于是改造为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <typename  T>
class smater_ptr {
public:
explicit smater_ptr(
T* ptr = nullptr)
: ptr_(ptr) {}
~smater_ptr()
{
delete ptr_;
}
T* get() const { return ptr_; }
private:
T* ptr_;
};

针对”该类对象的行为不够像指针”,我们想到了指针的基本操作有*->,布尔表达式。

于是添加三个成员函数:

1
2
3
4
5
6
7
8
9
10
11
12
template <typename  T>
class smater_ptr {
public:
...
T& operator*() const { return *ptr_; }//返回引用,作为左值
T* operator->() const { return ptr_; }//返回目的类型的指针
//语句 sp->m 被解释为 (sp.operator->())->m,即ptr_->m,通过对sp类操作就可以调用ptr_类的成员
operator bool() const { return ptr_; }
...
private:
T* ptr_;
};

针对”拷贝该类对象会引发程序行为”,我们想到了拷贝构造和赋值。

现考虑如下调用:

1
2
smart_ptr<shape> ptr1(create_shape(shape_type::circle));//create...表示new一个对象
smart_ptr<shape> ptr2(ptr1);

对于第二行,究竟应当让编译时发生错误,还是可以有一个更合理的行为?我们来逐一检查 一下各种可能性。

最简单的情况显然是禁止拷贝。我们可以使用下面的代码:

1
2
3
4
5
6
7
8
9
template <typename T>
class smart_ptr {

smart_ptr(const smart_ptr&)
= delete;
smart_ptr& operator=(const smart_ptr&)
= delete;

};

当然,也可以设为private。

禁用这两个函数非常简单,但却解决了一种可能出错的情况:smart_ptr<shape> ptr2(ptr1); 在编译时不会出错,但在运行时却会有未定义行为——由于会对同一内存释放两次,通常情况下会导致程序崩溃。

我们是不是可以考虑在拷贝智能指针时把对象拷贝一份?不行,通常人们不会这么用,因为使用智能指针的目的就是要减少对象的拷贝啊。何况,虽然我们的指针类型是 shape,但实际指向的却应该是 circle 或 triangle 之类的对象。在 C++ 里没有像 Java 的clone 方法这样的约定;一般而言,并没有通用的方法可以通过基类的指针来构造出一个子类的对象来。

那关键点就来了,所有权!,我们可以拷贝时转移指针的所有权!下面实现便是auto_ptr的核心实现:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
template<typename T>
class auto_ptr {
public:
explicit auto_ptr(
T *ptr = nullptr) noexcept
: ptr_(ptr) {}

~auto_ptr() noexcept {
delete ptr_;
}
// 返回值为T&,允许*ptr=10操作
T &operator*() const noexcept { return *ptr_; }

T *operator->() const noexcept { return ptr_; }

operator bool() const noexcept { return ptr_; }

T *get() const noexcept { return ptr_; }

// 拷贝构造,被复制放释放原来指针的所有权,交给复制方
auto_ptr(auto_ptr &other) noexcept {
ptr_ = other.release();
}

// copy and swap
auto_ptr &operator=(auto_ptr &rhs) noexcept {
// auto_ptr tmp(rhs.release());拷贝构造copy
// tmp.swap(*this);交换swap
// s上述两行等价于下面一行
auto_ptr(rhs.release()).swap(*this);
return *this;
}

// 原来的指针释放所有权
T *release() noexcept {
T *ptr = ptr_;
ptr_ = nullptr;
return ptr;
}

void swap(auto_ptr &rhs) noexcept {
using std::swap;
swap(ptr_, rhs.ptr_); // 转移指针所有权
}

private:
T *ptr_;
};

template<typename T>
void swap(auto_ptr<T> &lhs, auto_ptr<T> &rhs) noexcept {
lhs.swap(rhs);
}

int main() {
auto_ptr<shape> ptr1{create_shape(shape_type::circle)};
auto_ptr<shape> ptr2{ptr1};
if (ptr1.get() == nullptr && ptr2.get())
cout << "拷贝构造:ptr1释放了所有权,ptr2获得了所有权" << endl;
ptr1 = ptr1;

auto_ptr<shape> ptr3{create_shape(shape_type::rectangle)};
ptr1 = ptr3;

if (ptr3.get() == nullptr && ptr1.get())
cout << "赋值操作:始终只有一个对象管理一个区块!ptr3释放了所有权,ptr1获得了所有权" << endl;
}

上述通过copy-swap技术完成了避免自我赋值与保证了强异常安全!

如果你觉得这个实现还不错的话,那恭喜你,你达到了 C++ 委员会在 1998 年时的水平:上面给出的语义本质上就是 C++98 的 auto_ptr 的定义。如果你觉得这个实现很别扭的话,也恭喜你,因为 C++ 委员会也是这么觉得的:auto_ptr 在 C++17 时已经被正式从C++ 标准里删除了

上面会导致什么问题呢?

看一下输出结果:

1
2
3
4
5
6
shape
circle
拷贝构造:ptr1释放了所有权,ptr2获得了所有权
shape
rectangle
赋值操作:始终只有一个对象管理一个区块!ptr3释放了所有权,ptr1获得了所有权

shape与circle是在create_shape时候输出的,我们重点关注最后一句话,发现了一个很大的问题:它的行为会让程序员非常容易犯错。一不小心把它传递给另外一个 auto_ptr,你就不再拥有这个对象了。

针对这个问题,在C++11标准出来之前,C++98标准中都一直只有一个智能指针auto_ptr,我们知道,这是一个失败的设计。它的本质是管理权的转移,这有许多问题。而这时就有一群人开始扩展C++标准库的关于智能指针的部分,他们组成了boost社区,他们负责boost库的开发和维护。其目的是为C++程序员提供免费的、同行审查的、可移植的程序库。boost库可以和C++标准库完美的共同工作,并且为其提供扩展功能。现在的C++11标准库的智能指针很大程度上“借鉴”了boost库。

boost::scoped_ptr 属于 boost 库,定义在 namespace boost 中,包含头文件#include<boost/smart_ptr.hpp> 可以使用。scoped_ptr 跟 auto_ptr 一样,可以方便的管理单个堆内存对象,特别的是,scoped_ptr 独享所有权,避免了auto_ptr恼人的几个问题。

scope_ptr是一种简单粗暴的设计,它本质就是防拷贝,避免出现管理权的转移。这是它的最大特点,所以他的拷贝构造函数和赋值运算符重载函数都只是声明而不定义,而且为了防止有的人在类外定义,所以将函数声明为private。但这也是它最大的问题所在,就是不能赋值拷贝,也就是说功能不全。但是这种设计比较高效、简洁。没有 release() 函数,不会导致先前的内存泄露问题。下面我也将模拟实现scoped_ptr的管理机制(实际上就是前面提到的禁止拷贝):

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
38
39
40
41
42
43
44
template<class T>
class scoped_ptr // noncopyable
{

public:
explicit scoped_ptr(T *ptr = 0) noexcept : ptr_(ptr) {
}

~scoped_ptr() noexcept {
delete ptr_;
}

void reset(T *p = 0) noexcept {
scoped_ptr(p).swap(*this);//使用构造函数构造一个临时对象,然后交换指针,让临时对象把原来的空间析构掉
}

T &operator*() const noexcept {
return *ptr_;
}

T *operator->() const noexcept {
return ptr_;
}

T *get() const noexcept {
return ptr_;
}

void swap(scoped_ptr &rhs) noexcept {
using std::swap;
swap(ptr_, rhs.ptr_);
}

private:
T *ptr_;
//在private里禁用,也可以delete
scoped_ptr(scoped_ptr const &);//只需声明,然后不提供实现即可
scoped_ptr &operator=(scoped_ptr const &);
};

template<typename T>
void swap(scoped_ptr<T> &lhs, scoped_ptr<T> &rhs) noexcept {
lhs.swap(rhs);
}

scoped_ptr特点总结:

  • 与auto_ptr类似,采用栈上的指针去管理堆上的内容,从而使得堆上的对象随着栈上对象销毁时自动删除(栈自动删除->析构函数->释放堆空间)

  • scoped_ptr有着更严格的使用限制——不能拷贝,这也意味着scoped_ptr不能转换其所有权,所以它管理的对象不能作为函数的返回值,对象生命周期仅仅局限于一定区间(该指针所在的{}区间,因为不允许拷贝和赋值,对象与scoped_ptr紧紧地绑定在了一起,受限于{}的栈,而std::auto_ptr管理的对象可以在不同的区间存活);

  • 由于防拷贝的特性,使其管理的对象不能共享所有权,这与std::auto_ptr类似(一个是独享,一个是转移,都不是共享),这一特点使该指针简单易用,但也造成了功能的薄弱

手写unique_ptr之子类向基类转换

在上述auto_ptr基础上,我们把拷贝构造与拷贝赋值,改为移动构造与移动赋值(参考语法记录内的相关内容)。

  • noexcept:该关键字告诉编译器,函数中不会发生异常,这有利于编译器对程序做更多的优化。
    如果在运行时,noexecpt函数向外抛出了异常(如果函数内部捕捉了异常并完成处理,这种情况不算抛出异常),程序会直接终止,调用std::terminate()函数,该函数内部会调用std::abort()终止程序。
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
38
39
40
41
42
43
44
45
46
47
48
template<typename T>
class unique_ptr {
public:
explicit unique_ptr(
T *ptr = nullptr) noexcept
: ptr_(ptr) {}

~unique_ptr() noexcept {
delete ptr_;
}

T &operator*() const noexcept { return *ptr_; }

T *operator->() const noexcept { return ptr_; }

operator bool() const noexcept { return ptr_; }

T *get() const noexcept { return ptr_; }

unique_ptr(unique_ptr &&other) noexcept {
ptr_ = other.release();
}

// copy and swap 始终只有一个对象有管理这块空间的权限
unique_ptr &operator=(unique_ptr rhs) noexcept {
rhs.swap(*this);
return *this;
}

// 原来的指针释放所有权
T *release() noexcept {
T *ptr = ptr_;
ptr_ = nullptr;
return ptr;
}

void swap(unique_ptr &rhs) noexcept {
using std::swap;
swap(ptr_, rhs.ptr_); // 转移指针所有权
}

private:
T *ptr_;
};
template<typename T>
void swap(unique_ptr<T> &lhs, unique_ptr<T> &rhs) {
lhs.swap(rhs);
}

调用:

1
2
3
4
5
6
7
8
9
10
int main() {
unique_ptr<shape> ptr1{create_shape(shape_type::circle)};
// unique_ptr<shape> ptr2{ptr1}; // error,没有拷贝构造函数
unique_ptr<shape> ptr2{std::move(ptr1)}; // ok,使用移动构造函数

unique_ptr<shape> ptr3{create_shape(shape_type::rectangle)};
// ptr1 = ptr3; // error,此时赋值构造函数通过拷贝构造函数实现(ptr3是左值),但没有
ptr3 = std::move(ptr1); // ok,使用赋值构造函数通过移动构造函数实现
}
//std::move将对象转化为右值

把拷贝构造函数中的参数类型 unique_ptr& 改成了 unique_ptr&&;现在它成了移动构造函数。 把赋值函数中的参数类型 unique_ptr& 改成了 unique_ptr,在构造参数时直接生成新的智能指针,从而不再需要在函数体中构造临时对象(相当于传入参数时,就构造了临时对象)。现在赋值函数的行为是移动还是拷贝,完全依赖于构造参数时走的是移动构造还是拷贝构造

最后,一个 circle* 是可以隐式转换成 shape*的,但上面的 unique_ptr<circle> 却无法自动转换成 unique_ptr<shape>(基类转子类,即unique_ptr<circle> = shape*是非法的,不能自动转换)。


现在我们考虑两种情况:

(1)第一种:当我们只是在原先的移动构造上面添加template <typename U>,此时情况是移动构造变为带模板的移动构造,可以进行子类向基类转换,但是与移动构造相关的,则调用的是默认移动构造,除非是子类向基类转换,才调用带模板的移动构造。

1
2
3
4
template <typename U>
unique_ptr(unique_ptr<U> &&other) noexcept {
ptr_ = other.release();
}

六个特殊的成员函数其生成规则如下:

  • 默认构造函数,生成规则和C++98一样,在用户没有声明自定义的构造函数的时候并且编译期需要的时候生成。
  • 析构函数,生成规则和C++98一样,在C++11中有点不同的是,析构函数默认是noexcept。
  • 拷贝构造函数,用户自定义了移动操作会导致不生成默认的拷贝构造函数,其它和C++98的行为一致。
  • 拷贝赋值操作符,用户自定义了移动操作会导致不生成默认的拷贝赋值操作,其它和C++98的行为一致。
  • 移动构造函数和移动赋值操作符,仅仅在没有用户自定义的拷贝操作、析构操作,移动操作的时候才会生成。因为默认移动构造函数出现应该在是在你需要它且能保证内存不被泄露的前提下才会出现。析构函数被显示定义的一个隐含说明就是说需要回收内存。

根据《Effective Modern C++》Item17 P115页提到,当类中含有特殊成员函数变为模板特殊成员函数的时候,此时不满足上述生成规则,也就是针对当前例子来说,编译器会默认生成拷贝构造,因为移动构造是模板函数(且没有非模板函数版本),所以此时上述main调用里面为error的都可以正常运行!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main() {
unique_ptr<shape> ptr1{create_shape(shape_type::circle)};
unique_ptr<shape> ptr2{ptr1}; // 由于带模板的移动构造函数引发编译器会默认生成拷贝构造
if (ptr1.get() != nullptr) // bitwise copy 此时ptr1不为NULL
ptr2.get()->print();

unique_ptr<shape> ptr2_2{std::move(ptr1)}; // 调用的是默认的移动构造,而不是带模板的移动构造 bitwise move
if (ptr2_2.get() != nullptr && ptr1.get() != nullptr) // ptr1 不为空
ptr2_2.get()->print();

unique_ptr<shape> ptr3{create_shape(shape_type::rectangle)};
ptr1 = ptr3; // ok 根据形参先调用默认拷贝(ptr3是左值),再调用拷贝赋值
ptr3 = std::move(ptr1); // ok 根据形参先调用默认移动构造(使用move后是右值),而不是带参数的移动构造,再调用移动赋值
unique_ptr<shape> ptr4(std::move(new circle)); // ok 调用带模板的移动构造
}

(2)第二种:移动构造与带模板的移动构造同时存在,可以完成子类向基类的转换,此时满足上述生成规则,但此时不会生成拷贝函数!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main() {
unique_ptr<shape> ptr1{create_shape(shape_type::circle)};
// unique_ptr<shape> ptr2{ptr1}; // error,无拷贝构造函数
unique_ptr<shape> ptr2_2{std::move(ptr1)}; // ok
if (ptr2_2.get() != nullptr && ptr1.get() == nullptr)
ptr2_2.get()->print();

unique_ptr<shape> ptr3{create_shape(shape_type::rectangle)};
// ptr1 = ptr3; // error,无拷贝构造函数,无法使用赋值构造函数(左值)
ptr3 = std::move(ptr1); // ok
// unique_ptr<circle> cl{create_shape(shape_type::circle)}; // error 因为create_shape返回的是shape 不能基类转子类
unique_ptr<circle> cl{new circle()};
unique_ptr<shape> ptr5(std::move(cl)); // ok unique<circle>转unique<circle>(即不用转换)
}

小结:

  • 我们需要了解子类向基类的隐式转换,通过将移动构造函数变为带模板的移动构造函数,要明白两者共存情况与只有带模板的移动或者其他构造函数对编译器生成规则的影响!上述代码,此时还不能完成基类向子类的转换!例如:unique_ptr<circle>unique_ptr<shape>

    • 如果只有带模板的拷贝/移动构造函数,则还是会生成默认的拷贝/移动构造函数,优先供同类型的对象使用,而带模板的供不同类型的对象使用。
  • auto_ptr与unique_ptr都是独占所有权,每次只能被单个对象所拥有,unique_ptr与auto_ptr不同的是使用移动语义来显式的编写

  • auto_ptr是可以说你随便赋值,但赋值完了之后原来的对象就不知不觉的报废,搞得你莫名其妙。而unique_ptr就干脆不让你可以随便去复制,赋值。如果实在想传个值就哪里显式的说明内存转移std:move一下。然后这样传值完了之后,之前的对象也同样报废了。只不过整个move你让明显的知道这样操作后会导致之前的unique_ptr对象失效

  • scope_ptr则是直接不允许拷贝。由于防拷贝的特性,使其管理的对象不能共享所有权

shared_ptr之引用计数

unique_ptr 算是一种较为安全的智能指针了。但是,一个对象只能被单个 unique_ptr所拥有,这显然不能满足所有使用场合的需求。一种常见的情况是,多个智能指针同时拥有一个对象;当它们全部都失效时,这个对象也同时会被删除。这也就是 shared_ptr 了。

两者区别如下:

多个shared_ptr不仅共享一个对象,同时还得共享同一个计数当最后一个指向对象(和共享计数)的shared_ptr析构时,它需要删除对象和共享计数。

首先需要一个共享计数的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class shared_count {
public:
shared_count() : count_(1) {//一旦初始化,赋1

}

// 增加计数
void add_count() {
++count_;
}

// 减少计数
long reduce_count() {
return --count_;
}

// 获取当前计数
long get_count() const {
return count_;
}

private:
long count_;
};

接下来实现引用计数智能指针:

构造与析构、swap实现如下所示:

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
template<typename T>
class shared_ptr {
public:
explicit shared_ptr(
T *ptr = nullptr) noexcept
: ptr_(ptr) {
if (ptr) {
shared_count_ = new shared_count();//初始化一个计数
}
}

~shared_ptr() noexcept {
// 最后一个shared_ptr再去删除对象与共享计数
// ptr_不为空且此时共享计数减为0的时候,再去删除
if(ptr_&&!shared_count_->reduce_count()) {
delete ptr_;
delete shared_count_;
}
}

void swap(shared_ptr &rhs) noexcept {
using std::swap;
swap(ptr_, rhs.ptr_);
swap(shared_count_,rhs.shared_count_);
}

private:
T *ptr_;
shared_count *shared_count_;
};
template<typename T>
void swap(shared_ptr<T> &lhs, shared_ptr<T> &rhs) noexcept {
lhs.swap(rhs);
}

之前的赋值函数,编译器可以根据调用来决定是调拷贝构造还是移动构造函数,所以不变:

1
2
3
4
5
// copy and swap  始终只有一个对象有管理这块空间的权限
shared_ptr &operator=(shared_ptr rhs) noexcept {
rhs.swap(*this);
return *this;
}

拷贝构造与移动构造需要改变:

除复制指针之外,对于拷贝构造的情况,我们需要在指针非空时把引用数加一,并复制共享计数的指针。对于移动构造的情况,我们不需要调整引用数,直接把 other.ptr_ 置为空,认为 other 不再指向该共享对象即可

实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template<typename U>
shared_ptr(const shared_ptr<T> &other) noexcept {//拷贝构造
ptr_ = other.ptr_; //1.数据指针共享
if (ptr_) {
other.shared_count_->add_count();//3.计数+1
shared_count_ = other.shared_count_;//2.计数指针共享
}
}

template<typename U>
shared_ptr(shared_ptr<U> &&other) noexcept {//移动构造
ptr_ = other.ptr_;
if (ptr_) {
shared_count_ = other.shared_count_;
other.ptr = nullptr;//移除数据指针
other.shared_count_ = nullptr;//移除计数指针
}
}

当运行的时候,报错:

1
‘circle* shared_ptr<circle>::ptr_’ is private

错误原因是模板的各个实例间并不天然就有 friend 关系,因而不能互访私有成员 ptr_ shared_count_。我们需要在 shared_ptr 的定义中显式声明:

1
2
3
4
template<typename U>
friend class shared_ptr;//使得可以直接在不同模板实例间互相访问,不需要指定shared_ptr<U>。
//声明一个模板函数作为友元也是,不需要加<U>
//若是声明一种特例,直接friend class A<int>; 函数则 friend void fun<int>();

对于这个问题,需要进一步解释:

类的private成员,只能由这个类所访问(不论是这个类的哪个实例,在类的作用域就行)。而对于模板类,实际上不同模板的实例并不属于一个类,比如说A<int>A<double>就不能互相访问,一个私有成员 x 是属于A<int>这个类的,那么就不属于A<double>这个类,但是A<int>这个类的不同实例是可以互相访问 x 的。

因此在shared_ptr这个类中声明友元,也就声明了所有这个类的模板,都是友元。


此外,在当前引用计数实现中,我们应该删除release释放所有权函数,编写一个返回引用计数值的函数。

1
2
3
4
5
6
7
long use_count() const noexcept {
if (ptr_) {
return shared_count_->get_count();
} else {
return 0;
}
}

调用:

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
shared_ptr<circle> ptr1(new circle());
cout << "use count of ptr1 is " << ptr1.use_count() << endl;
shared_ptr<shape> ptr2, ptr3;
cout << "use count of ptr2 was " << ptr2.use_count() << endl;
ptr2 = ptr1; // shared_ptr<circle>隐式转换shared_ptr<shape> 调用带模板的拷贝构造
// cout<<"======="<<endl;
// ptr3 = ptr2; // 调用的是编译器生成的默认拷贝构造 所以引用计数不会增加 ptr3=ptr2
// cout<<"======="<<endl;
ptr3 = ptr1;
cout << "此时3个shared_ptr指向同一个资源" << endl;
cout << "use count of ptr1 is now " << ptr1.use_count() << endl;
cout << "use count of ptr2 is now " << ptr2.use_count() << endl;
cout << "use count of ptr3 is now " << ptr3.use_count() << endl;
if (ptr1)
cout << "ptr1 is not empty" << endl;
// 会先调用赋值函数,由编译器决定调用的是拷贝构造还是移动构造,造出一个新的临时对象出来,临时对象会在跳出作用域后被析构掉。
// 在析构函数中,会先判断该临时对象的是否指向资源,如果没有,析构结束。否则,对引用计数减1,判断引用计数是否为0,如果为0,删除共享引用计数指针,否则不操作。
cout << "此时2个shared_ptr指向同一个资源" << endl;
ptr2 = std::move(ptr1);
if (!ptr1 && ptr2) { // 调用的是bool重载操作符
cout << "ptr1 move to ptr2" << endl;
cout << "use count of ptr1 is now " << ptr1.use_count() << endl;
cout << "use count of ptr2 is now " << ptr2.use_count() << endl;
cout << "use count of ptr3 is now " << ptr3.use_count() << endl;
}

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
shape
circle
use count of ptr1 is 1
use count of ptr2 was 0
此时3个shared_ptr指向同一个资源
use count of ptr1 is now 3
use count of ptr2 is now 3
use count of ptr3 is now 3
ptr1 is not empty
此时2个shared_ptr指向同一个资源
ptr1 move to ptr2
use count of ptr1 is now 0
use count of ptr2 is now 2
use count of ptr3 is now 2
~circle
~shape

有几点注意事项:

  • 上述代码没有考虑线程安全性,这里只是简化版
  • =赋值重载函数不加&,编译器决定调用拷贝构造还是移动构造,来造出一个临时对象出来。
  • 根据前面提到的,当类中特殊函数变为带模板的函数,编译器仍然会生成默认拷贝构造与默认移动构造。

针对第一点:例如:ptr2 = std::move(ptr1);

会先调用赋值函数,由编译器决定调用的是拷贝构造还是移动构造,造出一个新的临时对象出来,临时对象会在跳出作用域后被析构掉。在析构函数中,会先判断该临时对象的是否指向资源,如果没有,析构结束。否则,对引用计数减1,判断引用计数是否为0,如果为0,删除共享引用计数指针,否则不操作。

针对第二点:

1
2
shared_ptr<shape> ptr2, ptr3;//两个引用计数
ptr3 = ptr2; // 调用的是编译器生成的默认拷贝构造 所以引用计数不会增加

两者都是一种类型,所以在调用赋值操作后,不会调用带模板的拷贝构造来创建临时变量,而是调用编译器生成的默认拷贝构造,所以此时引用计数不会增加。

指针类型转换

对应于 C++ 里的不同的类型强制转:

  • dynamic_cast
  • static_cast
  • const_cast
  • reinterpret_cast

dynamic_cast

在上述unique_ptr处实现了子类向基类的转换,但是却没有实现基类向子类的转换,例如::unique_ptr<circle>unique_ptr<shape>

实现这种,需要使用dynamic_cast,实现如下:

首先为了实现这些转换,我们需要添加构造函数,允许在对智能指针内部的指针对象赋值时,使用一个现有的智能指针的共享计数。

1
2
3
4
5
6
7
8
9
// 实现强制类型转换需要的构造函数
template<typename U>
shared_ptr(const shared_ptr<U> &other, T *ptr) noexcept {
ptr_ = ptr;
if (ptr_) {
other.shared_count_->add_count();
shared_count_ = other.shared_count_;
}
}

其次,就是实现转换函数:

1
2
3
4
5
template<typename T, typename U>
shared_ptr<T> dynamic_pointer_cast(const shared_ptr<U> &other) noexcept {
T *ptr = dynamic_cast<T *>(other.get());//强制转换为T*
return shared_ptr<T>(other, ptr);//使用上述的构造函数,返回临时对象
}

调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// shape* -> circle* 使用dynamic_cast转换后,指针为空。此时资源还是被dptr2拥有,dptr1为0
shared_ptr<shape> dptr2(new shape);
shared_ptr<circle> dptr1 = dynamic_pointer_cast<circle>(dptr2); // 基类转子类

cout << "use count of dptr1 is now " << dptr1.use_count() << endl; // 0
cout << "use count of dptr2 is now " << dptr2.use_count() << endl; // 1

// circle* -> circle* 使用dynamic_cast转换后,指针不为空,此时资源被两者共同使用,引用计数为2
shared_ptr<shape> dptr3(new circle);
// shared_ptr<circle> dptr3(new circle); // 上面或者当前行,后面输出一样!
shared_ptr<circle> dptr1_1 = dynamic_pointer_cast<circle>(dptr3); // 基类转子类

cout << "use count of dptr1_1 is now " << dptr1_1.use_count() << endl; // 2
cout << "use count of dptr3 is now " << dptr3.use_count() << endl; // 2

// circle* -> circle* 使用dynamic_cast转换后,指针不为空,此时资源被两者共同使用,引用计数为2
shared_ptr<circle> dptr3_1(new circle);
shared_ptr<shape> dptr2_1 = dynamic_pointer_cast<shape>(dptr3_1); // 子类转基类 上行转换,安全!

cout << "use count of dptr2_1 is now " << dptr2_1.use_count() << endl; // 2
cout << "use count of dptr3_1 is now " << dptr3_1.use_count() << endl; // 2

dynamic_cast主要用于类层次间的上行转换和下行转换,还可以用于类之间的交叉转换。在类层次间进行上行转换时,dynamic_cast和static_cast的效果是一样的;在进行下行转换时,dynamic_cast具有类型检查的功能,比static_cast更安全。在多态类型之间的转换主要使用dynamic_cast,因为类型提供了运行时信息。

  • 下行转换,基类转换为子类(派生类指针指向基类对象),例如:智能指针转换类似于shape* 转换为circle* 使用dynamic_cast转换后,,指针为空。此时资源还是被dptr2拥有,dptr1为0。比static_cast安全。
  • 平行转换,指向一致的相互转换,例如:智能指针转换类似于circle*转换为circle*。此时引用计数为两者共享
  • 上行转换,子类转基类,例如:智能指针转换类似于circle*转换为shape*,此时引用技术为两者共享。等价于static_cast。

static_cast

同样,编写如下:

1
2
3
4
5
template<typename T, typename U>
shared_ptr<T> static_pointer_cast(const shared_ptr<U> &other) noexcept {
T *ptr = static_cast<T *>(other.get());
return shared_ptr<T>(other, ptr);
}

调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// shape* -> circle* 使用static_cast转换后,指针为空  与dynamic_cast相比,不安全
shared_ptr<shape> sptr2(new shape);
shared_ptr<circle> sptr1 = static_pointer_cast<circle>(sptr2); // 基类转子类

cout << "use count of sptr1 is now " << dptr1.use_count() << endl; // 0
cout << "use count of sptr2 is now " << dptr2.use_count() << endl; // 1

// circle* -> circle* 使用dynamic_cast转换后,指针不为空,此时资源被两者共同使用,引用计数为2
shared_ptr<shape> sptr3(new circle);
// shared_ptr<circle> sptr3(new circle); // 上面或者当前行,后面输出一样!
shared_ptr<circle> sptr1_1 = static_pointer_cast<circle>(sptr3); // 基类转子类

cout << "use count of sptr1_1 is now " << sptr1_1.use_count() << endl; // 2
cout << "use count of sptr3 is now " << sptr3.use_count() << endl; // 2

// circle* -> circle* 使用static_cast转换后,指针不为空,此时资源被两者共同使用,引用计数为2 等价于dynamic_cast
shared_ptr<circle> sptr3_1(new circle);
shared_ptr<shape> sptr2_1 = static_pointer_cast<shape>(sptr3_1); // 子类转基类 上行转换,安全!

cout << "use count of sptr2_1 is now " << sptr2_1.use_count() << endl; // 2
cout << "use count of sptr3_1 is now " << sptr3_1.use_count() << endl; // 2

输出结果同上dynamic_cast,不同之处,在下行转换的时候(基类转子类),是不安全的!

还可以将non-const对象强制转换为const:static_cast<const A&>(*this):将本身(A&)转换为const A&。

const_cast

去掉const属性:

1
2
3
4
5
6
template<typename T, typename U>
shared_ptr<T> const_pointer_cast(
const shared_ptr<U> &other) noexcept {
T *ptr = const_cast<T *>(other.get());
return shared_ptr<T>(other, ptr);
}

调用:

1
shared_ptr<circle> s = const_pointer_cast<circle>(shared_ptr<const circle>(new circle));

reinterpret_cast

例如:想把一个指针转为整数,就可以用reinterpret_cast。

1
2
3
4
5
6
template<typename T, typename U>
shared_ptr<T> reinterpret_pointer_cast(
const shared_ptr<U> &other) noexcept {
T *ptr = reinterpret_cast<T *>(other.get());
return shared_ptr<T>(other, ptr);
}

调用:

1
int a = reinterpret_pointer_cast<int>(s);

在基本的语法学习差不多学习完之后,来学一下编程风格惯用法。因为c++的语法支持我们以多种形式编写代码,但有些语法内容还需要进一步探讨,应该有一个明确的规范,这就是惯用法的作用。实际上有些部分在语法内容也有提到,这里再总结一下。当我们在编写这类代码时,应当遵循惯用法。

参考自GitHub项目:CPlusPlusThings

初始化列表与赋值

本章学习编程过程中,何时用初始化列表,何时直接赋值。

总结:

  • const成员的初始化只能在构造函数初始化列表中进行。
  • 引用成员的初始化也只能在构造函数初始化列表中进行。
  • 对象成员(对象成员所对应的类没有默认构造函数)的初始化,也只能在构造函数初始化列表中进行(调用拷贝构造函数)。

下面具体学习一下:

类之间嵌套

这个比较重要,后面介绍的继承关系一律用初始化列表构造。

第一种: 使用初始化列表

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
class Animal {
public:
Animal() {
std::cout << "Animal() is called" << std::endl;
}

Animal(const Animal &) {
std::cout << "Animal (const Animal &) is called" << std::endl;
}

Animal &operator=(const Animal &) {
std::cout << "Animal & operator=(const Animal &) is called" << std::endl;
return *this;
}

~Animal() {
std::cout << "~Animal() is called" << std::endl;
}
};

class Dog {
public:
Dog(const Animal &animal) : __animal(animal) {//第一种方式,调用拷贝构造
std::cout << "Dog(const Animal &animal) is called" << std::endl;
}

~Dog() {
std::cout << "~Dog() is called" << std::endl;
}

private:
Animal __animal;
};

int main() {
Animal animal;
std::cout << std::endl;
Dog d(animal);
std::cout << std::endl;
return 0;
}

//运行结果
Animal() is called
//构造,先构造成员对象(如果有多,按照声明的顺序),再调用类自己的构造函数
Animal (const Animal &) is called
Dog(const Animal &animal) is called

//析构,后定义的先析构;调用析构函数后再析构成员变量
~Dog() is called
~Animal() is called
~Animal() is called

依次分析从上到下:

main函数中Animal animal;调用默认构造。

Dog d(animal);且初始化对象是一个类成员对象,等价于定义同时初始化:

1
Animal __animal = animal;

实际上就是调用了拷贝构造,因此输出了:

1
Animal (const Animal &) is called

再然后打印Dog的构造函数里面的输出。

最后调用析构,程序结束。

在初始化列表中不一定要调用拷贝构造,也可以调用默认构造函数或者有参构造函数。比如这里调用

1
2
3
Dog(const Animal &animal) : __animal()//第二种方式,默认构造函数
Dog(const Animal &animal) : __animal(5)//第二种方式,有参构造函数(如果有)
Dog(int x) : __animal(x)//第二种方式,有参构造函数,进一步指定变量
1
2
3
4
5
6
7
8
Animal() is called

Animal() is called
Dog(const Animal &animal) is called

~Dog() is called
~Animal() is called
~Animal() is called

也就是说,在初始化列表中初始化,等同于定义同时初始化(因此既能够使用构造函数(传入其他类型)、也能使用拷贝构造函数(传入类自己的类型)),而不是先声明。

第二种:构造函数赋值来初始化对象

构造函数修改如下:

1
2
3
4
Dog(const Animal &animal) {
__animal = animal;
std::cout << "Dog(const Animal &animal) is called" << std::endl;
}

此时输出结果:

1
2
3
4
5
6
7
8
9
Animal() is called

Animal() is called
Animal & operator=(const Animal &) is called
Dog(const Animal &animal) is called

~Dog() is called
~Animal() is called
~Animal() is called

于是得出:

当调用Dog d(animal);时,等价于:

先定义对象,再进行赋值,因此先调用了默认构造(因此如果没有默认构造函数会出错),再调用=操作符重载函数(如果没重载赋值构造函数,使用默认的)。

1
2
3
// 假设之前已经有了animal对象
Animal __animal;
__animal = animal;

小结

通过上述我们得出如下结论:

  • 类中包含其他自定义的class或者struct,采用初始化列表,实际上就是创建对象同时并初始化(可用构造和拷贝构造,取决于参数类型)
  • 而采用类中赋值方式,等价于先定义对象,再进行赋值,一般会先调用默认构造,在调用=操作符重载函数。

无默认构造函数的继承关系

现考虑把上述嵌套的关系改为继承,并修改Animal与Dog的构造函数,如下代码:

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
class Animal {
public:
Animal(int age) {//有参,非默认构造函数,且编译器不提供默认构造函数
std::cout << "Animal(int age) is called" << std::endl;
}

Animal(const Animal & animal) {
std::cout << "Animal (const Animal &) is called" << std::endl;
}

Animal &operator=(const Animal & amimal) {
std::cout << "Animal & operator=(const Animal &) is called" << std::endl;
return *this;
}

~Animal() {
std::cout << "~Animal() is called" << std::endl;
}
};

class Dog : Animal {
public:
Dog(int age) : Animal(age) {//继承构造的标准形式,如果无参或默认构造,使用Animal()
std::cout << "Dog(int age) is called" << std::endl;
}

~Dog() {
std::cout << "~Dog() is called" << std::endl;
}

};

上述是通过初始化列表给基类带参构造传递参数,如果不通过初始化列表传递,会发生什么影响?

去掉初始化列表

1
2
3
Dog(int age)  {
std::cout << "Dog(int age) is called" << std::endl;
}

运行程序:

1
error: no matching function for call to ‘Animal::Animal()’

由于在Animal中没有默认构造函数,所以报错,遇到这种问题属于灾难性的,我们应该尽量避免,可以通过初始化列表给基类的构造初始化。

类中const数据成员、引用数据成员

特别是引用数据成员,必须用初始化列表初始化,而不能通过赋值初始化!

例如:在上述的Animal中添加私有成员,并修改构造函数:

1
2
3
4
5
6
7
8
9
class Animal {
public:
Animal(int age,std::string name) {
std::cout << "Animal(int age) is called" << std::endl;
}
private:
int &age_;//引用,定义同时必须初始化
const std::string name_;//const类型,定义同时必须初始化
};

报下面错误:

1
error: uninitialized reference member in ‘int&’

应该改为下面:

1
2
3
Animal(int age, std::string name) : age_(age), name_(name) {//使用初始化列表,相当于定义同时初始化
std::cout << "Animal(int age) is called" << std::endl;
}

枚举类与命名空间

在Effective modern C++中Item 10: Prefer scoped enums to unscoped enum,要用有范围的enum class代替无范围的enum

例如:

1
2
enum Shape {circle,retangle};
auto circle = 10; // error

上述错误是因为两个circle在同一范围。 对于enum等价于:

1
2
#define circle 0
#define retangle 1

因此后面再去定义circle就会出错。

所以不管枚举名是否一样,里面的成员只要有一致的,就会出问题。 例如:

1
2
enum A {a,b};
enum B {c,a};

a出现两次,在enum B的a处报错。

根据前面我们知道,enum名在范围方面没有什么作用,因此我们想到了namespace,如下例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 在创建枚举时,将它们放在名称空间中,以便可以使用有意义的名称访问它们:
namespace EntityType {
enum Enum {
Ground = 0,
Human,
Aerial,
Total
};
}

void foo(EntityType::Enum entityType)
{
if (entityType == EntityType::Ground) {//使得Ground在EntityType空间才是全局的
/*code*/
}
}

将命名空间起的有意思点,就可以达到想要的效果。

但是不断的使用命名空间,势必太繁琐,而且如果我不想使用namespace,要达到这样的效果,便会变得不安全,也没有约束。

因此在c++11后,引入enum class

enum class 解决了为enum成员定义类型、类型安全、约束等问题。 回到上述例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// enum class
enum class EntityType {
Ground = 0,
Human,
Aerial,
Total
};

void foo(EntityType entityType)
{
if (entityType == EntityType::Ground) {//Ground已非全局,属于枚举类的成员
/*code*/
}
}

这便是这一节要阐述的惯用法:enum class。

资源获取即初始化方法(RAII)

RAII 是 resource acquisition is initialization 的缩写,意为“资源获取即初始化”。它是 C++ 之父 Bjarne Stroustrup 提出的设计理念,其核心是把资源和对象的生命周期绑定,对象创建获取资源,对象销毁释放资源。在 RAII 的指导下,C++ 把底层的资源管理问题提升到了对象生命周期管理的更高层次。

导语

在C语言中,有三种类型的内存分配:静态、自动和动态。静态变量是嵌入在源文件中的常数,因为它们有已知的大小并且从不改变,所以它们并不那么有趣。自动分配可以被认为是堆栈分配——当一个词法块进入时分配空间,当该块退出时释放空间。它最重要的特征与此直接相关。在C99之前,自动分配的变量需要在编译时知道它们的大小。这意味着任何字符串、列表、映射以及从这些派生的任何结构都必须存在于堆中的动态内存中。

程序员使用四个基本操作明确地分配和释放动态内存:malloc、realloc、calloc和free。前两个不执行任何初始化,内存可能包含碎片。除了自由,他们都可能失败。在这种情况下,它们返回一个空指针,其访问是未定义的行为。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
int main() {
char *str = (char *) malloc(7);
strcpy(str, "toptal");
printf("char array = \"%s\" @ %u\n", str, str);

str = (char *) realloc(str, 11);
strcat(str, ".com");
printf("char array = \"%s\" @ %u\n", str, str);

free(str);

return(0);
}

输出:

1
2
char array = "toptal" @ 2762894960
char array = "toptal.com" @ 2762894960

尽管代码很简单,但它已经包含了一个反模式和一个有问题的决定。在现实生活中,你不应该直接写字节数,而应该使用sizeof函数。类似地,我们将char *数组精确地分配给我们需要的字符串大小的两倍(比字符串长度多一倍,以说明空终止),这是一个相当昂贵的操作。一个更复杂的程序可能会构建一个更大的字符串缓冲区,允许字符串大小增长。

RAII的发明:新希望

至少可以说,所有手动管理都是令人不快的。 在80年代中期,Bjarne Stroustrup为他的全新语言C ++发明了一种新的范例。 他将其称为“资源获取即初始化”,其基本见解如下:可以指定对象具有构造函数和析构函数,这些构造函数和析构函数在适当的时候由编译器自动调用,这为管理给定对象的内存提供了更为方便的方法。 并且该技术对于不是内存的资源也很有用。

意味着上面的例子在c++中更简洁:

1
2
3
4
5
6
7
8
9
int main() {
std::string str = std::string ("toptal");
std::cout << "string object: " << str << " @ " << &str << "\n";

str += ".com";
std::cout << "string object: " << str << " @ " << &str << "\n";

return(0);
}

输出:

1
2
string object: toptal @ 0x7fffa67b9400
string object: toptal.com @ 0x7fffa67b9400

在上述例子中,我们没有手动内存管理!构造string对象,调用重载方法,并在函数退出时自动销毁。不幸的是,同样的简单也会导致其他问题。让我们详细地看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
vector<string> read_lines_from_file(string &file_name) {
vector<string> lines;
string line;

ifstream file_handle (file_name.c_str());
while (file_handle.good() && !file_handle.eof() && file_handle.peek()!=EOF) {
getline(file_handle, line);
lines.push_back(line);
}

file_handle.close();

return lines;
}

int main(int argc, char* argv[]) {
// get file name from the first argument
string file_name (argv[1]);
int count = read_lines_from_file(file_name).size();
cout << "File " << file_name << " contains " << count << " lines.";

return 0;
}

输出:

1
File makefile contains 37 lines.

这看起来很简单。vector被填满、返回和调用。然而,作为关心性能的高效程序员,这方面的一些问题困扰着我们:在return语句中,由于使用了值语义,vector在销毁之前不久就被复制到一个新vector中(伴随main函数一直存在)。

在现代C ++中,这不再是严格的要求了。 C ++ 11引入了移动语义的概念,其中将原点保留在有效状态(以便仍然可以正确销毁)但未指定状态。 对于编译器而言,返回调用是最容易优化以优化语义移动的情况,因为它知道在进行任何进一步访问之前不久将销毁源。 但是,该示例的目的是说明为什么人们在80年代末和90年代初发明了一大堆垃圾收集的语言,而在那个时候C ++ move语义不可用。

对于数据量比较大的文件,这可能会变得昂贵。 让我们对其进行优化,只返回一个指针。 语法进行了一些更改,但其他代码相同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
vector<string> * read_lines_from_file(string &file_name) {
vector<string> * lines;
string line;

ifstream file_handle (file_name.c_str());
while (file_handle.good() && !file_handle.eof() && file_handle.peek()!=EOF) {
getline(file_handle, line);
lines->push_back(line);
}

file_handle.close();

return lines;
}
int main(int argc, char* argv[]) {
// get file name from the first argument
string file_name (argv[1]);
int count = read_lines_from_file(file_name).size();
cout << "File " << file_name << " contains " << count << " lines.";

return 0;
}

输出:

1
Segmentation fault (core dumped)

程序崩溃!我们只需要将上述的lines进行内存分配:

1
vector<string> * lines = new vector<string>;

这样就可以运行了!

不幸的是,尽管这看起来很完美,但它仍然有一个缺陷:它会泄露内存。在C++中,指向堆的指针在不再需要后必须手动删除(此时类对象的析构函数无法帮忙);否则,一旦最后一个指针超出范围,该内存将变得不可用,并且直到进程结束时操作系统对其进行管理后才会恢复。惯用的现代C++将在这里使用unique_ptr,它实现了期望的行为。它删除指针超出范围时指向的对象。然而,这种行为直到C++11才成为语言的一部分。

在这里,可以直接使用C++11之前的语法,只是把main中改一下即可:

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
vector<string> * read_lines_from_file(string &file_name) {
vector<string> * lines = new vector<string>;
string line;

ifstream file_handle (file_name.c_str());
while (file_handle.good() && !file_handle.eof() && file_handle.peek()!=EOF) {
getline(file_handle, line);
lines->push_back(line);
}

file_handle.close();

return lines;
}

int main(int argc, char* argv[]) {
// get file name from the first argument
string file_name (argv[1]);
vector<string> * file_lines = read_lines_from_file(file_name);
int count = file_lines->size();
delete file_lines;
cout << "File " << file_name << " contains " << count << " lines.";

return 0;
}

手动去分配内存与释放内存。

不幸的是,随着程序扩展到上述范围之外,很快就变得更加难以推理指针应该在何时何地被删除。当一个函数返回指针时,你现在拥有它吗?您应该在完成后自己删除它,还是它属于某个稍后将被一次性释放的数据结构?一方面出错,内存泄漏,另一方面出错,你已经破坏了正在讨论的数据结构和其他可能的数据结构,因为它们试图取消引用现在不再有效的指针。

“使用垃圾收集器,flyboy!”

垃圾收集器不是一项新技术。 它们由John McCarthy在1959年为Lisp发明。 1980年,随着Smalltalk-80的出现,垃圾收集开始成为主流。 但是,1990年代代表了该技术的真正发芽:在1990年至2000年之间,发布了多种语言,所有语言都使用一种或另一种垃圾回收:Haskell,Python,Lua,Java,JavaScript,Ruby,OCaml 和C#是最著名的。

什么是垃圾收集? 简而言之,这是一组用于自动执行手动内存管理的技术。 它通常作为具有手动内存管理的语言(例如C和C ++)的库提供,但在需要它的语言中更常用。 最大的优点是程序员根本不需要考虑内存。 都被抽象了。 例如,相当于我们上面的文件读取代码的Python就是这样:

1
2
3
4
5
6
7
8
9
10
11
12
def read_lines_from_file(file_name):
lines = []
with open(file_name) as fp:
for line in fp:
lines.append(line)
return lines

if __name__ == '__main__':
import sys
file_name = sys.argv[1]
count = len(read_lines_from_file(file_name))
print("File {} contains {} lines.".format(file_name, count))

行数组是在第一次分配给它时出现的,并且不复制到调用范围就返回。 由于时间不确定,它会在超出该范围后的某个时间被垃圾收集器清理。 有趣的是,在Python中,用于非内存资源的RAII不是惯用语言。 允许我们可以简单地编写fp = open(file_name)而不是使用with块,然后让GC(Garbage Collection,垃圾回收)清理。 但是建议的模式是在可能的情况下使用上下文管理器,以便可以在确定的时间释放它们。

尽管简化了内存管理,但要付出很大的代价。 在引用计数垃圾回收中,所有变量赋值和作用域出口都会获得少量成本来更新引用。在标记清除系统中,在GC清除内存的同时,所有程序的执行都以不可预测的时间间隔暂停。 这通常称为世界停止事件(stop the world)。「具体来说,就是当程序运行期间,若可以使用的内存被耗尽的时候,GC线程就会被触发并将程序暂停,随后将依旧存活的对象标记一遍,最终再将堆中所有没被标记的对象全部清除掉,接下来便让程序恢复运行。」 同时使用这两种系统的Python之类的实现都会受到两种惩罚。 这些问题降低了垃圾收集语言在性能至关重要或需要实时应用程序的情况下的适用性。 即使在以下玩具程序上,也可以看到实际的性能下降:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ make cpp && time ./c++ makefile
g++ -o c++ c++.cpp
File makefile contains 38 lines.
real 0m0.016s
user 0m0.000s
sys 0m0.015s

$ time python3 python3.py makefile
File makefile contains 38 lines.

real 0m0.041s
user 0m0.015s
sys 0m0.015s

Python版本的实时时间几乎是C ++版本的三倍。 尽管并非所有这些差异都可以归因于垃圾收集,但它仍然是可观的。

GC算法和特点参考:GC算法 - 寐语者 - 博客园 (cnblogs.com)

所有权:RAII觉醒

我们知道对象的生存期由其范围决定。 但是,有时我们需要创建一个对象,该对象与创建对象的作用域无关,这是有用的,或者很有用。 在C ++中,运算符new用于创建这样的对象。 为了销毁对象,可以使用运算符delete。 由new操作员创建的对象是动态分配的,即在动态内存(也称为堆或空闲存储)中分配。 因此,由new创建的对象将继续存在,直到使用delete将其明确销毁为止。

使用new和delete时可能发生的一些错误是:

  • 对象(或内存)泄漏:使用new分配对象,而忘记删除该对象。

  • 过早删除(或悬挂引用):持有指向对象的另一个指针,删除该对象,然而还有其他指针在引用它。

  • 双重删除:尝试两次删除一个对象。

通常,范围变量是首选。 但是,RAII可以用作new和delete的替代方法,以使对象独立于其范围而存在。 这种技术包括将指针分配到在堆上分配的对象,并将其(堆空间/对象)放在句柄/管理器对象中。 后者具有一个析构函数,将负责销毁该对象。 这将确保该对象可用于任何想要访问它的函数,并且该对象在句柄对象的生存期结束时将被销毁,而无需进行显式清理。

来自C ++标准库的使用RAII的示例为std :: string和std :: vector。

考虑这段代码:

1
2
3
4
5
6
7
void fn(const std::string& str)
{
std::vector<char> vec;
for (auto c : str)
vec.push_back(c);
// do something
}

当创建vector,并将元素推入vector时,您不必担心分配和取消分配此类元素内存。 vector使用new为其堆上的元素分配空间,并使用delete释放该空间。 作为vector的用户,您无需关心实现细节,并且会相信vector不会泄漏。 在这种情况下,vector是其元素的句柄对象。

标准库中使用RAII的其他示例是std :: shared_ptr,std :: unique_ptr和std :: lock_guard。

该技术的另一个名称是SBRM,是范围绑定资源管理的缩写。

现在,我们将上述读取文件例子,进行修改:

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
#include <iostream>
#include <vector>
#include <cstring>
#include <fstream>
#include <bits/unique_ptr.h>

using namespace std;
unique_ptr<vector<string>> read_lines_from_file(string &file_name) {
unique_ptr<vector<string>> lines(new vector<string>);
string line;

ifstream file_handle (file_name.c_str());
while (file_handle.good() && !file_handle.eof() && file_handle.peek()!=EOF) {
getline(file_handle, line);
lines->push_back(line);
}

file_handle.close();

return lines;
}
int main(int argc, char* argv[]) {
// get file name from the first argument
string file_name (argv[1]);
int count = read_lines_from_file(file_name).get()->size();
cout << "File " << file_name << " contains " << count << " lines.";

return 0;
}

只有在最后,你才意识到RAII的真正力量。

自从编译器发明以来,手动内存管理是程序员一直在想办法避免的噩梦。 RAII是一种很有前途的模式,但由于没有一些奇怪的解决方法,它根本无法用于堆分配的对象,因此在C ++中会受到影响。 因此,在90年代出现了垃圾收集语言的爆炸式增长,旨在使程序员生活更加愉快,即使以性能为代价。

最后,RAII总结如下:

  • 资源在析构函数中被释放

  • 该类的实例是堆栈分配的

  • 资源是在构造函数中获取的

RAII代表“资源获取是初始化”。

常见的例子有:

  • 文件操作

  • 智能指针

  • 互斥量

拷贝交换copy-swap

这部分内容比较难懂,但特别巧妙。我花了挺长时间(起码比前面部分要多得多)理解,期间收集了多方的资料,并根据自己的理解整合(缝合)了一下,形成了一个比较完好、比较易懂的逻辑块,最后进行了一下总结。

首先介绍一下异常安全的概念(主要是针对析构函数,也可以直接跳到copy and swap惯用法章节)

异常安全

当异常发生时,会进行栈展开stack unwinding,具体步骤为:

将暂停当前函数的执行,开始查找匹配的 catch 子句。首先检查 throw 本身是否在 try 块内部,如果是,检查与该 try 相关的 catch 子句,看是否有匹配的catch。如果不能处理,就退出当前函数,并且释放当前函数的局部对象,继续到上层的调用函数中查找,直到找到一个可以处理该异常的 catch 。这个过程称为栈展开(stack unwinding)。当处理该异常的 catch 结束之后,紧接着该 catch 之后的点继续执行。

显然栈展开跟对象离开函数作用域,自动析构的功能一样,是为了避免内存泄漏。而stack unwinding只能对栈上的变量析构,堆上动态分配的new不会自动析构。所以当发生异常时,要特别当心内存泄漏的发生

异常处理威力很大,是处理错误的不二之选,但有时我们并不希望在有些函数中抛出异常,如:

  • 析构函数不可以抛出异常,详见下文

  • 构造函数可以抛出异常;

    • 如果在构造函数对象时发生异常,此时该对象可能只是被部分构造,根据栈展开的原理,会把已经构造好的对象自动析构;
  • 移动赋值函数不应该抛出异常;

    • 在STL标准库中很多容器在resize时都会通过std::move_if_noexcept模板来判断元素是否提供了noexcept(无异常)的移动赋值,如果提供那么move,否则调用拷贝赋值函数。所以不抛出异常的移动赋值函数效率会更高。
  • 拷贝赋值函数可以抛出异常

  • swap不应该抛出异常

    • 根据copy and swap惯用法,swap是移动赋值函数的基石。swap不抛出异常,移动赋值才不会抛出异常。

note:未捕获的异常将会终止程序。如果找不到匹配的catch,程序就会调用库函数std::terminate。

析构中的异常安全

提问:析构函数可以抛出异常吗?答案是:不应该也不能。

理由有二,假设某类能抛出异常:

  1. vector析构所有元素时,那么当有一个元素抛出异常,此时catch之后的处理显然是继续销毁剩下的元素,但是假设运气很不好,又有一个元素抛出异常,c++此时无能为力,要么结束执行,要么发生不预期的行为。
  2. 析构函数往往不仅仅释放一个资源,当前一个资源释放时抛出异常,此时跳过异常点后面的代码,使得后一块资源没有释放,造成内存泄漏

因此得出结论,即便析构函数抛出了异常,程序猿catch后也无法处理这烫手山芋。不抛出异常是一种及时止损,如果抛会引起其他不可以预期的行为。c++资源释放不许失败。如果失败了,也不去管它,不抛异常让他去,let it go。(因为其他不可预期行为比资源释放失败更难以接受)

那么当析构函数发生错误时,该怎么办呢:

  • 只好忍气吞声(吞下异常);
  • 直接终止程序;
  • 其他释放会失败的资源,建议释放不要放析构,放第三方函数,让程序员手工操作;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class DBConn{
public:
DBConn::~DBConn();
};
// 直接终止
DBConn::~DBConn{
try{db.close();}
catch(...){
记录日志;
std::abort();
}
}
//吞下异常
DBConn::~DBConn{
try{db.close();}
catch(...){
记录日志;
}
}

尽管吞下异常是个坏主意,但是没有办法的办法。

其他释放会失败的资源,建议释放不要放析构,放第三方函数,让程序锁手工操作

比如数据库连接断开操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class DBConn{
public:
void close(){
db.close();
closed=true;
}
~DBConn(){
if(!closed){
try{
db.close();
}
catch(...){
记录日志;
}
}
}
private:
DBconnection db;
bool closed;
};

把数据库连接释放这样可能出错的操作交给程序员自行释放,如果程序猿没有自行释放,但由析构函释放。那么此时析构函数出错,程序员也无话可说。

此外c++11之后,默认会把析构函数看成noexcept(true),这意味着**如果析构函数抛出异常,直接std::terminal**。

swap

本节是copy and swap的铺垫。

交换函数是一种不抛异常函数,它交换一个类的两个对象或者成员。我们可能很想使用std :: swap而不是提供我们自己的方法,但这是不可能的。 std :: swap在实现中使用了copy-constructor和copy-assignment运算符,我们最终将尝试根据自身定义赋值运算符。

(不仅如此,对swap的无条件调用将使用我们的自定义swap运算符,从而跳过了std :: swap会导致的不必要的类构造和破坏。)

std中的swap是这么写的,应尽可能地对类的成员变量使用std::swap,而不是对整个类使用std::swap(如注释所写,若对整个类使用,在内部会调用拷贝构造和赋值构造,会导致的不必要的类构造和破坏)。实际上若使用copy-swap,赋值构造函数会需要调用swap函数,也就是说在使用swap函数时,赋值构造函数并没有完成。

swap必须注重安全,不允许抛出异常。(std::swap是安全的)

1
2
3
4
5
6
7
8
namespace std{
template<typename T>
void swap(T& a,T& b){
T temp(a); //拷贝构造,如果对象是类
a = b;
b = temp;//赋值构造,如果对象是类
}
}

copy and swap惯用法

为什么我们需要复制和交换习惯?

任何管理资源的类(包装程序,如智能指针)都需要实现big three。尽管拷贝构造函数和析构函数的目标和实现很简单。

big three(亦即下列三个成员函数缺一不可):

  • 析构函数(Destructor)
  • 拷贝构造函数(copy constructor)
  • 赋值构造函数(copy assignment operator)

但是赋值构造函数无疑是最细微和最困难的。

应该怎么做?需要避免什么陷阱?

copy-swap是解决方案,可以很好地协助赋值运算符实现两件事:避免代码重复,并提供强大的异常保证

它是如何工作的?

从概念上讲,它通过使用拷贝构造函数的功能来创建数据的本地副本,然后使用交换功能获取复制的数据,将旧数据与新数据交换来工作。然后,临时副本将销毁,并随身携带旧数据。我们剩下的是新数据的副本。

为了使用copy-swap,我们需要三件事:

  • 一个有效的拷贝构造函数
  • 一个有效的析构函数(两者都是任何包装程序的基础,因此无论如何都应完整)以及交换功能(swap)。

实现方式对比

我们先不考虑存在继承关系的类的赋值运算符重写,只考虑最简单的情况。我们知道,按照C++ primer的理解,赋值运算符应该实现两个方面的工作:

  • 拷贝构造函数
  • 析构函数。

只有完整实现了上述两步工作,赋值运算才能够正确进行。

首先介绍自赋值安全和异常安全:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//当是自赋值的时候,pb已经先被删除了,那么后面的new就会为空,这是未知的计算。
Widget& Widget::operator=(const Widget& rhs){
delete pb; // stop using current bitmap
pb = new Bitmap(*rhs.pb); // start using a copy of rhs's bitmap
return *this; // see Item 10
}

/*
异常安全是指当异常发生时:
1) 不会泄漏资源,
2) 也不会使系统处于不一致的状态。
通常有三个异常安全级别:基本保证、强烈保证、不抛异常(nothrow)保证。
*/

//这个自赋值安全,但是没有异常安全,如果new处出现了异常,那么pb仍旧指向空。
Widget& Widget::operator=(const Widget& rhs){
if (this == &rhs) return *this;
delete pb; // stop using current bitmap
pb = new Bitmap(*rhs.pb); // start using a copy of rhs's bitmap
return *this; // see Item 10
}

下面给出类A的定义,注意到类A中数据成员的数据类型,分别是内置整型以及整型指针。据此给出了构造以及析构函数。

1
2
3
4
5
6
7
8
9
10
11
12
class A {
private:
int *b;
int a;
public:
A():a(0),b(nullptr){};
A(const A&rhs):a(rhs.a),b(rhs.b==nullptr?nullptr:new int(*rhs.b)){};//拷贝
~A(){//析构
delete b;
b = nullptr;
};
};

赋值运算符包括拷贝构造以及析构两方面,因此给出第一种定义:

1
2
3
4
5
6
7
8
9
//第一种写法
A& operator=(const A& rhs) {
if(this!=&rhs) { // 防止自赋值
delete b;
this->b = new int(*rhs.b);// 可能失败
this->a = rhs.a;
}
return *this; // 返回this对象的引用
}

可以看到我们的代码几乎是对拷贝构造函数和析构函数的完全复制,此外,上述代码虽然完成了自赋值的验证,但并未保障异常安全。一旦new失败,原this对象的b已经被删除,因此会引发异常(若再使用b取值)。

effective C++ 关于本节的条款提到,无须在意自赋值,更多地考虑异常安全,异常安全得到保证,则自赋值自然得到处理。回到当前的例子,异常不安全主要在于,b对应的对象可能在异常到来之前被删除。因此我们首先保存该对象的副本,从而保证了异常安全特性,无论new是否成功,this对象中的b指针都会指向已知对象:

1
2
3
4
5
6
7
8
//第二种写法
A& operator=(const A& rhs) {
auto orign = this->b;
this->b = new int(*rhs.b);
delete orign;
this->a = rhs.a;
return *this;
}

该写法不仅是异常安全的,同时也能够处理自赋值,但冗余代码的问题仍未得到解决,在effective C++中提到,可以写一个private函数进行调用,可是,这种写法并未解决根本问题:我们在赋值运算中重复实现了拷贝构造函数和析构函数

上述方法事实上是致命的。在不考虑继承关系的复杂情况下,如果更改类A,添加数据成员,我们在修改其它构造/析构函数的同时,也必须修改赋值运算符。copy and swap技术则可以做到完全规避这一点,此外,所有调用工作由编译器自动完成,无需再做任何额外操作。

该技术的核心就是不再使用引用作为赋值运算符参数,形参将直接是对象,这样的写法将会使编译器自动调用拷贝构造函数,由于拷贝构造函数的调用,异常安全将在进入函数体之前被避免(若拷贝失败则什么都不会发生,因为所有的swap是安全、不抛出异常的)。经过swap后的对象在离开函数体后会自动销毁,因此也就自动调用了析构函数,具体写法如下:

1
2
3
4
5
6
7
8
9
10
11
12
//第三种写法,copy-swap
void swap(A& rhs) {
using std::swap;
swap(this->a,rhs.a);//赋值或调用赋值构造函数(如果a是一个类对象),这导致rhs被销毁后,类本身的数据仍然存在
swap(this->b,rhs.b);//浅拷贝,把指针内容(地址)交换,
//使得rhs调用析构函数时,释放的空间是原来的this->b的,而新的数据空间仍可以使用,一举两得
}

A& operator=(A rhs) {
swap(rhs);
return *this;
}

我们的代码有着显而易见的优势:所有需要考虑的问题会由编译器处理,我们无需考虑任何事项,关键是,它的正确性是显而易见而且符合逻辑的。对于类的扩展,我们除了构造函数/析构函数外,只需要修改swap函数即可。


考虑存在继承的复杂情形

本节对应的内容是effective C++ 条款12,复制对象时勿忘记复制其每一成分。 假设有如下类B继承自上述类A:

1
2
3
4
5
6
7
8
9
10
11
class B : public A {
private:
int ab;
public:
B():ab(0){}
B(const B&rhs):ab(rhs.ab){} // copy constructor
B& operator=(const B&rhs){
this->ab = rhs.ab; // assignment operator
return *this;
}
};

上述写法有两个错误,首先,B的拷贝构造函数只复制了B的数据成员,对于父类A中的私有成员,并没有进行复制,因此没有做到复制所有成员,对此拷贝构造函数需要修改为:

1
B(const B&rhs):A(rhs),ab(rhs.ab){} // copy constructor

同理:赋值运算符也应修改为:

1
2
3
4
5
B& operator=(const B&rhs){
A::operator=(rhs);
this->ab = rhs.ab; // assignment operator
return *this;
}

对于采用拷贝交换技术的类,我们则调用其父类的swap函数:

1
2
3
4
5
6
7
8
9
10
void swap(B& rhs) {
using std::swap;
A::swap(rhs);
swap(this->ab,rhs.ab);
}

B& operator=(B rhs) {
swap(rhs);
return *this;
}

总结

copy-swap惯用法实际上是利用编译器调用拷贝构造函数和析构函数来实现赋值构造函数(大前提是赋值构造函数的动作基本与拷贝构造函数(数据复制)和析构函数(原数据清除)相似)。

  • 基本的操作是通过传入函数的形参不是引用完成的(不是引用的话,则传入的参数会调用拷贝构造,当离开作用域时又会调用析构函数,使得赋值构造函数本身不用重复写这些代码)。
    • 这同时使得赋值构造函数避免了自赋值(因为传入的形参是一个临时对象);
    • 同时保证异常安全,由于拷贝构造函数的调用,异常安全将在进入函数体之前被避免(若拷贝失败则什么都不会发生,因为所有的swap是安全、不抛出异常的)。
  • 然后把拷贝构造函数产生的对象拿来swap,为什么是swap而非继续直接赋值呢?
    • 这主要是考虑对象中有指针与堆空间的释放,如果将指针直接赋值,则在临时对象析构后,赋值后指向的空间立马就释放了,做了无用功;同时又不保证异常安全了(空指针)。
    • 更重要的是,对象自身的指针也指向堆空间,直接赋值就导致内存空间未释放就丢失,明显是不行的。
    • 使用swap即可一举两得,swap一方面把自身指针的地址交换给临时对象的指针地址,当对方调用析构函数时释放掉这块空间;同时使原来拷贝构造出来的数据空间不会丢失。

指向实现的指针

“指向实现的指针”或“pImpl”是一种 C++ 编程技巧,它将类的实现细节从对象表示中移除,放到一个分离的类中,并以一个不透明的指针进行访问。

使用pImpl惯用法的原因如下:

考虑如下例子:

1
2
3
4
5
6
class X
{
private:
C c;
D d;
} ;

变成pImpl就是下面这样子

1
2
3
4
5
6
class X
{
private:
struct XImpl;
XImpl* pImpl;
};

CPP定义:

1
2
3
4
5
struct X::XImpl
{
C c;
D d;
};
  • 二进制兼容性

开发库时,可以在不破坏与客户端的二进制兼容性的情况下向XImpl添加/修改字段(这将导致崩溃!)。 由于在向Ximpl类添加新字段时X类的二进制布局不会更改,因此可以安全地在次要版本更新中向库添加新功能。

当然,也可以在不破坏二进制兼容性的情况下向X / XImpl添加新的公共/私有非虚拟方法,但这与标准的标头/实现技术相当。

  • 数据隐藏

如果您正在开发一个库,尤其是专有库,则可能不希望公开用于实现库公共接口的其他库/实现技术。 要么是由于知识产权问题,要么是因为认为用户可能会被诱使对实现进行危险的假设,或者只是通过使用可怕的转换技巧来破坏封装。 PIMPL解决/缓解了这一难题。

  • 编译时间

编译时间减少了,因为当向XImpl类添加/删除字段和/或方法时(仅映射到标准技术中添加私有字段/方法的情况),仅需要重建X的源(实现)文件。 实际上,这是一种常见的操作。

使用标准的标头/实现技术(没有PIMPL),当向X添加新字段时,曾经重新分配X(在堆栈或堆上)的每个客户端都需要重新编译,因为它必须调整分配的大小 。 好吧,每个从未分配X的客户端也都需要重新编译,但这只是开销(客户端上的结果代码是相同的)。

常对象

在 C++ 中,const 也可以用来修饰对象,称为常对象。一旦将对象定义为常对象之后,就只能调用类的 const 成员(包括 const 成员变量和 const 成员函数)了。

定义常对象的语法和定义常量的语法类似:

1
2
const  class  object(params);
class const object(params);

当然你也可以定义 const 指针:

1
2
const class *p = new class(params);
class const *p = new class(params);

class为类名,object为对象名,params为实参列表,p为指针名。两种方式定义出来的对象都是常对象。

一旦将对象定义为常对象之后,不管是哪种形式,该对象就只能访问被 const 修饰的成员了(包括 const 成员变量和 const 成员函数),因为非 const 成员可能会修改对象的数据(编译器也会这样假设),C++禁止这样做。

虽然常对象中的数据成员不能被修改,但是如果想要修改可以通过修改数据成员声明为mutable

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
#include <iostream>
using namespace std;

class CTest
{
private:
mutable int n;
public:
CTest(int x) :n(x) {};
void display() const;

};

void CTest::display() const
{
n++;
cout << n << endl;
}
int main()
{

const CTest test(3);
test.display();

return 0;
}

也可以用于区分重载函数:

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
38
39
#include<iostream>
using namespace std;
class R
{ public:
R(int r1, int r2){R1=r1;R2=r2;}
//const区分成员重载函数
void print();
void print() const;
private:
int R1,R2;
};
/*
常成员函数说明格式:类型说明符 函数名(参数表)const;
这里,const是函数类型的一个组成部分,因此在实现部分也要带const关键字。
const关键字可以被用于参与对重载函数的区分
通过常对象只能调用它的常成员函数
*/

void R::print()
{
cout<<"普通调用"<<endl;
cout<<R1<<":"<<R2<<endl;
}
//实例化也需要带上const
void R::print() const
{
cout<<"常对象调用"<<endl;
cout<<R1<<";"<<R2<<endl;
}
int main()
{
R a(5,4);
a.print(); //调用void print()
//通过常对象只能调用它的常成员函数
const R b(20,52);
b.print(); //调用void print() const
system("pause");
return 0;
}

const成员函数的总结:

  • const成员函数可以访问非const对象的非const数据成员、const数据成员,也可以访问const对象内的所有数据成员(但不对任何数据作修改,除非是mutable的);
  • 非const成员函数可以访问非const对象的非const数据成员、const数据成员,但不可以访问const对象的任意数据成员;
  • 作为一种良好的编程风格,在声明一个成员函数时,若该成员函数并不对数据成员进行修改操作,应尽可能将该成员函数声明为const 成员函数。
  • 如果只有const成员函数,非const对象是可以调用const成员函数的。当const版本和非const版本的成员函数同时出现时,非const对象调用非const成员函数。

默认构造函数

1
2
3
4
5
6
7
8
9
10
11
class testClass
{
public:
testClass(); /* 默认构造函数 */
testClass(int a, char b); /* 构造函数 */
testClass(int a=10,char b='c'); /* 默认构造函数 */

private:
int m_a;
char m_b;
};

默认构造函数主要是用来完成如下形式的初始化的:

1
2
1 testClass classA;
2 // 或者 testClass *classA = new testClass;

在这种情况下,如果没有提供默认构造函数,编译器会报错;

非默认构造函数在调用时接受参数,如以下形式:

1
2
1 testClass classA(12,'H');
2 //或者 testClass *classA = new testClass(12,'H');
  • 如果没有定义任何构造函数,则编译器会自动定义默认构造函数,其形式如 testClass() {}; (比如定义了拷贝构造函数,也就不会自动生成默认构造函数)
  • 定义默认构造函数有两种方式,如上述代码展示的,一是定义一个无参的构造函数,二是定义所有参数都有默认值的构造函数
  • 注意:一个类只能有一个默认构造函数!也就是说上述两种方式不能同时出现,一般选择 testClass(); 这种形式的默认构造函数 ;
  • 只要定义了构造函数,编译器就不会再提供默认构造函数了,所以,最好再手动定义一个默认构造函数,以防出现 testClass a; 这样的错误。

拷贝构造函数

复制构造函数是构造函数的一种,也称拷贝构造函数,它只有一个参数,参数类型是本类的引用。

复制构造函数的参数可以是 const 引用,也可以是非 const 引用。 一般使用前者,这样既能以常量对象(初始化后值不能改变的对象)作为参数,也能以非常量对象作为参数去初始化其他对象。一个类中写两个复制构造函数,一个的参数是 const 引用,另一个的参数是非 const 引用,也是可以的。

如果类的设计者不写复制构造函数,编译器就会自动生成复制构造函数。大多数情况下,其作用是实现从源对象到目标对象逐个字节的复制,即使得目标对象的每个成员变量都变得和源对象相等。编译器自动生成的复制构造函数称为“默认复制构造函数”。

注意,默认构造函数(即无参构造函数)不一定存在,但是复制构造函数总是会存在

复制构造函数在以下三种情况下会被调用。

  • 1.当用一个对象去初始化同类的另一个对象时,会引发复制构造函数被调用。例如,下面的两条语句都会引发复制构造函数的调用,用以初始化 c2。

    1
    2
    Complex c2(c1);
    Complex c2 = c1;
    • 注意,第二条语句是初始化语句,不是赋值语句。赋值语句的等号左边是一个早已有定义的变量,赋值语句不会引发复制构造函数的调用。例如

      1
      2
      Complex c1, c2; c1 = c2 ;
      c1=c2;
  • 2.如果函数 F 的参数是类 A 的对象,那么当 F 被调用时,类 A 的复制构造函数将被调用。换句话说,作为形参的对象,是用复制构造函数初始化的,而且调用复制构造函数时的参数,就是调用函数时所给的实参。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    #include<iostream>
    using namespace std;
    class A{
    public:
    A(){};
    A(A & a){
    cout<<"Copy constructor called"<<endl;
    }
    };
    void Func(A a){ }
    int main(){
    A a;
    Func(a);
    return 0;
    }
    • 这样,如果形参是一个对象,那么形参的值是否等于实参,取决于该对象所属的类的复制构造函数是如何实现的。

    • 以对象作为函数的形参,在函数被调用时,生成的形参要用复制构造函数初始化,这会带来时间上的开销。如果用对象的引用而不是对象作为形参,就没有这个问题了。但是以引用作为形参有一定的风险,因为这种情况下如果形参的值发生改变,实参的值也会跟着改变。

    • 如果要确保实参的值不会改变,又希望避免复制构造函数带来的开销,解决办法就是将形参声明为对象的 const 引用。例如:

      1
      2
      3
      4
      void Function(const Complex & c)
      {
      ...
      }
    • 这种情况下,只能调用c的const成员函数和const成员。

  • 3.如果函数的返冋值是类 A 的对象,则函数返冋时,类 A 的复制构造函数被调用。换言之,作为函数返回值的对象是用复制构造函数初始化的,而调用复制构造函数时的实参,就是 return 语句所返回的对象。例如下面的程序:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    #include<iostream>
    using namespace std;
    class A {
    public:
    int v;
    A(int n) { v = n; };
    A(const A & a) {
    v = a.v;
    cout << "Copy constructor called" << endl;
    }
    };
    A Func() {
    A a(4);
    return a;
    }
    int main() {
    cout << Func().v << endl;
    return 0;
    }

构造函数的default和delete

C++11中,当类中含有不能默认初始化的成员变量时,可以禁止默认构造函数的生成,

1
2
3
myClass()=delete;//表示删除默认构造函数

myClass()=default;//表示默认存在构造函数

当类中含有不能默认拷贝成员变量时,可以禁止默认构造函数的生成,

1
2
3
myClass(const myClass&)=delete;//表示删除默认拷贝构造函数,即不能进行默认拷贝

myClass & operator=(const myClass&)=delete;//表示删除默认赋值构造函数,即不能进行默认赋值

同时C++规定,一旦程序员实现了这些函数的自定义版本,则编译器不会再自动生产默认版本。注意只是不自动生成默认版本,当然还是可手动生成默认版本的。当我们自己定义了待参数的构造函数时,我们最好是声明不带参数的版本以完成无参的变量初始化,此时编译是不会再自动提供默认的无参版本了。我们可以通过使用关键字default来控制默认构造函数的生成,显式地指示编译器生成该函数的默认版本。比如:

1
2
3
4
5
6
7
8
class MyClass
{
public:
MyClass()=default; //同时提供默认版本和带参版本,类型是POD的
MyClass(int i):data(i){}
private:
int data;
};

有些时候我们希望限制默认函数的生成。典型的是禁止使用拷贝构造函数,以往的做法是将拷贝构造函数声明为private的,并不提供实现,这样当拷贝构造对象时编译不能通过,C++11则使用delete关键字显式指示编译器不生成函数的默认版本。比如:

1
2
3
4
5
6
7
class MyClass
{
public:
MyClass()=default;
MyClass(const MyClass& )=delete;
......
}

当然,一旦函数被delete过了,那么重载该函数也是非法的,该函数我们习惯上称为删除函数


default和delete的其他用途

上面我们已经看到在类中我们可用default和delete修饰成员函数,使之成为缺省函数或者删除函数,在类的外面,也可以在类定义之外修饰成员函数,比如:

1
2
3
4
5
6
7
8
class MyClass
{
public:
MyClass()=default;
MyClass() &operator=(const MyClass& );
);
//在类的定义外用default来指明缺省函数版本
inline MyClass& MyClass::operator=(const MyClass& )=default;

而关于delete的显式删除,并非局限于成员函数,由此我们也知default是只局限作用于类的部分成员函数的。于是我们还可用delete来避免不必要的隐式数据类型转换。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MyClass
{
public:
MyClass(int i){};
MyClsss(char c)=delete; //删除char版本的构造函数
};
void Fun(MyClass m){}
int main()
{
Func(3);
Func('a'); //编译不能通过,否则会自动把'a'转换成int
MyClass m1(3);
MyClass m2('a'); //编译不能通过
}

这是因为char版本的构造函数被删除后,试图从char构造MyClass对象的方式是不允许的了。但去掉这句的函数删除后,编译器会隐式的将a转换为整型使得编译通过,调用的是整型构造函数,这可能并不是你所想要的。但是如果这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MyClass
{
public:
MyClass(int i){};
explicit MyClsss(char c)=delete; //删除explicit的char版本的构造函数
};
void Fun(MyClass m){}
int main()
{
Func(3);
Func('a'); //编译可通过
MyClass m1(3);
MyClass m2('a'); //编译不能通过
}

将构造函数explicit后,构造函数一样的还是不能发生char的构造,因为char构造版本被删除了,但在Func的调用用,编译器会尝试将c转换为int,即Func(‘a’)会调用一次MyClass(int )构造,顺利通过编译。于是我们不提倡explicit和delete混用

对与普通函数delete也有类型的效果。比如:

1
2
3
4
5
6
7
8
void Func(int i){};
void Func(char c)=delete; //显式删除char版本
int main()
{
Func(3);
Func('c); //无法编译通过
return 0;
}

这里因为Func的char版本已经被删除,故Func(‘c’)会编译失败。

delete的有趣的用法还有删除operator new操作符,编码在堆上分配该类的对象如:

1
void* operator new(std::size_t)=delete;

另外析构函数也是可以delete的

这样做的目的是我们在指定内存位置进行内存分配时并不需要析构函数来完成对象级别的清理,这时我们可显式删除析构函数来限制自定义类型在栈上或者静态的构造。

移动构造函数

C++11中引入了移动构造函数,对象发生拷贝时不需要重新分配空间而是使用被拷贝对象的内存,从而提高代码运行效率

  • C++中对象发生拷贝的场景可以分为两种,一种是被拷贝的对象还要继续使用,另一种是被拷贝的对象不再使用;第二种一般可以认为是对右值的拷贝
    • &&是右值引用(即将消亡的值就是右值,函数返回的临时变量也是右值),右值可以匹配const &
    • &可以绑定左值(左值是指表达式结束后依然存在的持久对象,可被赋值)
  • 移动构造函数的第一个参数必须是自身类型的右值引用(不需要const,右值使用const没有意义),若存在额外的参数,任何额外的参数都必须有默认实参
  • 移动构造函数构造对象时不再分配新内存,而是接管源对象的内存,移动后源对象进入可被销毁的状态,所以源对象中如果有指针数据成员,那么它们应该在移动构造函数中应该赋值为NULL
  • 因为移动操作不分配内存,所以不会抛出任何异常,因此可以用noexcept指定(如果定义在类的外面,那么定义也要用noexcept指定)

当类中同时包含拷贝构造函数和移动构造函数时,如果使用临时对象初始化当前类的对象,编译器会优先调用移动构造函数来完成此操作。只有当类中没有合适的移动构造函数时,编译器才会退而求其次,调用拷贝构造函数

在实际开发中,通常在类中自定义移动构造函数的同时,会再为其自定义一个适当的拷贝构造函数,由此当用户利用右值初始化类对象时,会调用移动构造函数;使用左值(非右值)初始化类对象时,会调用拷贝构造函数。

如果使用左值初始化同类对象,但也想调用移动构造函数完成,有没有办法可以实现呢?

默认情况下,左值初始化同类对象只能通过拷贝构造函数完成,如果想调用移动构造函数,则必须使用右值进行初始化。C++11 标准中为了满足用户使用左值初始化同类对象时也通过移动构造函数完成的需求,新引入了 std::move() 函数,它可以将左值强制转换成对应的右值,由此便可以使用移动构造函数。

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
38
#include <iostream>
using namespace std;
class demo{
public:
demo():num(new int(0)){
cout<<"construct!"<<endl;
}

demo(const demo &d):num(new int(*d.num)){
cout<<"copy construct!"<<endl;
}
//添加移动构造函数
demo(demo &&d):num(d.num){
d.num = NULL;//修改源对象内部指针,指向NULL,使得析构后目标数据空间不会被释放
cout<<"move construct!"<<endl;
}
~demo(){
cout<<"class destruct!"<<endl;
}
private:
int *num;
};
demo get_demo(){
return demo();
}
int main(){
demo a = get_demo();
return 0;
}

/*
construct!
move construct!
class destruct!
move construct!
class destruct!
class destruct!
*/

通过执行结果我们不难得知,当为 demo 类添加移动构造函数之后,使用临时对象初始化 a 对象过程中产生的 2 次拷贝操作,都转由移动构造函数完成。

移动赋值函数

与移动构造函数类似,移动构造函数是拷贝函数的替代,移动赋值函数则是赋值构造函数的替代。

也是将原对象的东西赋值给新对象,然后原对象指向空

1
2
3
4
5
6
7
8
9
void operator = (A && x){//正常情况下,返回值为A&
this->num = x.num;
x.num = NULL;//修改指针指向NULL
cout << "移动赋值函数" << endl;
}
void operator = (A & x){
this->num = new int(*x.num);
cout << "operator=" << endl;
}

初始化列表

初始化类的成员有两种方式,一是使用初始化列表,二是在构造函数体内进行赋值操作。

使用初始化列表的原因:

  • 推荐使用初始化列表,它会比在函数体内初始化派生类成员更快,这是因为在分配内存后,在函数体内又多进行了一次赋值操作。

  • 常量成员,因为常量只能初始化不能赋值,所以必须放在初始化列表里面

  • 引用类型,引用必须在定义的时候初始化,并且不能重新赋值,所以也要写在初始化列表里面

  • 没有默认构造函数的类类型,因为使用初始化列表可以不必调用默认构造函数来初始化,而是直接调用拷贝构造函数初始化

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
class Test1
{
public:
Test1(int a):i(a){}//定义了构造函数,没有默认的
int i;
};
class Test2
{
public:
Test1 test1 ;//因为在类Test2中,会根据Test2的构造函数来决定这句话到底只是声明,还是要用构造函数
Test2(Test1 &t1)
{test1 = t1 ;}//这里表明test1已经构造好了,所以前面会执行默认构造函数,然后再执行赋值构造函数,会报错(注意这不是在声明时赋值,不是拷贝构造)
};

/*
以上代码无法通过编译,因为Test2的构造函数中test1 = t1这一行实际上分成两步执行:
1. 调用Test1的默认构造函数来初始化test1,2.执行赋值构造函数
由于Test1没有默认的构造函数,所以1 无法执行,故而编译错误。正确的代码如下,使用初始化列表代替赋值操作
*/

class Test2
{
public:
Test1 test1 ;//在类Test2中,会根据Test2的构造函数来决定这句话到底只是声明,还是要用构造函数
Test2(Test1 &t1):test1(t1){}//这里对编译器强调了“初始化”,即前面只是声明,真正的初始化在这里,所以在这里调用拷贝构造函数
}
  • 注意,成员变量的初始化顺序与初始化列表中列出的变量的顺序无关,它只与成员变量在类中声明的顺序有关。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    class foo
    {
    public:
    int i ;int j ;
    foo(int x):i(x), j(i){}; // ok, 先初始化i,后初始化j
    };

    class foo
    {
    public:
    int i ;int j ;
    foo(int x):j(x), i(j){} // i值未定义
    };

派生类对象赋值给基类对象

c++中经常会发生数据类型的转换,例如将 int 类型的数据赋值给 float 类型的变量时,编译器会先把 int 类型的数据转换为 float 类型再赋值;反过来,float 类型的数据在经过类型转换后也可以赋值给 int 类型的变量。

类其实也是一种数据类型,也可以发生数据类型转换,不过这种转换只有在基类和派生类之间才有意义,并且只能将派生类赋值给基类,包括将派生类对象赋值给基类对象、将派生类指针赋值给基类指针、将派生类引用赋值给基类引用,这在 C++ 中称为向上转型(Upcasting)。相应地,将基类赋值给派生类称为向下转型(Downcasting)。

向上转型非常安全,可以由编译器自动完成;向下转型有风险,需要程序员手动干预。

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
38
39
40
41
#include <iostream>
using namespace std;
//基类
class A{
public:
A(int a);
public:
void display();
public:
int m_a;
};
A::A(int a): m_a(a){ }
void A::display(){
cout<<"Class A: m_a="<<m_a<<endl;
}
//派生类
class B: public A{
public:
B(int a, int b);
public:
void display();
public:
int m_b;
};
B::B(int a, int b): A(a), m_b(b){ }
void B::display(){
cout<<"Class B: m_a="<<m_a<<", m_b="<<m_b<<endl;
}
int main(){
A a(10);
B b(66, 99);
//赋值前
a.display();
b.display();
cout<<"--------------"<<endl;
//赋值后
a = b;
a.display();
b.display();
return 0;
}

运行结果:

1
2
3
4
5
Class A: m_a=10
Class B: m_a=66, m_b=99
----------------------------
Class A: m_a=66
Class B: m_a=66, m_b=99

本例中 A 是基类, B 是派生类,a、b 分别是它们的对象,由于派生类 B 包含了从基类 A 继承来的成员,因此可以将派生类对象 b 赋值给基类对象 a。

赋值的本质是将现有的数据写入已分配好的内存中,对象的内存只包含了成员变量,所以对象之间的赋值是成员变量的赋值,成员函数不存在赋值问题。

实际上,为了执行赋值,派生类必须初始化好基类的成员变量。

派生类指针(引用)赋值给基类指针(引用)

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
#include <iostream>
using namespace std;
//基类A
class A{
public:
A(int a);
public:
void display();
protected:
int m_a;
};
A::A(int a): m_a(a){ }
void A::display(){
cout<<"Class A: m_a="<<m_a<<endl;
}
//中间派生类B
class B: public A{
public:
B(int a, int b);
public:
void display();
protected:
int m_b;
};
B::B(int a, int b): A(a), m_b(b){ }
void B::display(){
cout<<"Class B: m_a="<<m_a<<", m_b="<<m_b<<endl;
}
//基类C
class C{
public:
C(int c);
public:
void display();
protected:
int m_c;
};
C::C(int c): m_c(c){ }
void C::display(){
cout<<"Class C: m_c="<<m_c<<endl;
}
//最终派生类D
class D: public B, public C{
public:
D(int a, int b, int c, int d);
public:
void display();
private:
int m_d;
};
D::D(int a, int b, int c, int d): B(a, b), C(c), m_d(d){ }
void D::display(){
cout<<"Class D: m_a="<<m_a<<", m_b="<<m_b<<", m_c="<<m_c<<", m_d="<<m_d<<endl;
}
int main(){
A *pa = new A(1);
B *pb = new B(2, 20);
C *pc = new C(3);
D *pd = new D(4, 40, 400, 4000);
pa = pd;
pa -> display();
pb = pd;
pb -> display();
pc = pd;
pc -> display();
cout<<"-----------------------"<<endl;
cout<<"pa="<<pa<<endl;
cout<<"pb="<<pb<<endl;
cout<<"pc="<<pc<<endl;
cout<<"pd="<<pd<<endl;
return 0;
}

运行结果:

1
2
3
4
5
6
7
8
Class A: m_a=4
Class B: m_a=4, m_b=40
Class C: m_c=400
-----------------------
pa=0x9b17f8
pb=0x9b17f8
pc=0x9b1800
pd=0x9b17f8

本例中定义了多个对象指针,并尝试将派生类指针赋值给基类指针。与对象变量之间的赋值不同的是,对象指针之间的赋值并没有拷贝对象的成员,也没有修改对象本身的数据,仅仅是改变了指针的指向。

将派生类指针 pd 赋值给了基类指针 pa,从运行结果可以看出,调用 display() 函数时虽然使用了派生类的成员变量,但是 display() 函数本身却是基类的。也就是说,将派生类指针赋值给基类指针时,通过基类指针只能使用派生类的成员变量,但不能使用派生类的成员函数。

概括起来说就是:编译器通过指针来访问成员变量,指针指向哪个对象就使用哪个对象的数据;编译器通过指针的类型来访问成员函数,指针属于哪个类的类型就使用哪个类的函数。(注意一个是指针,一个是指针类型)

执行pc = pd;语句后,pc 和 pd 的值并不相等。这是因为D类先继承了B再继承了C,在内存模型中,D类实例的空间先存储了B类、再存储了C类,因此pa、pb直接指向内存空间的起始,而pc要指向C类的那一块空间, 因此在稍后一些的位置(B的结束、C的开始)。

引用在本质上是通过指针的方式实现的,基类的引用也可以指向派生类的对象,并且它的表现和指针是类似的。

1
2
3
4
5
6
7
8
9
10
11
12
int main(){
D d(4, 40, 400, 4000);

A &ra = d;
B &rb = d;
C &rc = d;

ra.display();
rb.display();
rc.display();
return 0;
}

运行结果

1
2
3
Class A: m_a=4
Class B: m_a=4, m_b=40
Class C: m_c=400

ra、rb、rc 是基类的引用,它们都引用了派生类对象 d,并调用了 display() 函数,从运行结果可以发现,虽然使用了派生类对象的成员变量,但是却没有使用派生类的成员函数,这和指针的表现是一样的。

最后需要注意的是,向上转型后通过基类的对象、指针、引用只能访问从基类继承过去的成员(包括成员变量和成员函数),不能访问派生类新增的成员。

类、派生类的构造

  • 类对象成员的构造:先构造成员变量中的对象,再构造自身对象(调用构造函数)

  • 派生类构造函数:派生类可能有多个基类,也可能包括多个成员对象,在创建派生类对象时,派生类的构造函数除了要负责本类成员的初始化外,还要调用基类和成员对象的构造函数,并向它们传递参数,以完成基类子对象和成员对象的建立和初始化。

    • 派生类只能采用构造函数初始化列表的方式向基类或成员对象的构造函数传递参数,形式如下:

      派生类构造函数名(参数表):基类构造函数名(参数表),成员对象名1(参数表),…{ //…… }

      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
      /*子类的拷贝构造函数和拷贝赋值函数*/
      #include <iostream>
      using namespace std;
      class Person{
      public:
      Person(int Age,string Name):m_nAge(Age),m_strName(Name){
      cout<<"Person:基类的代参构造函数"<<endl;
      }
      private:
      int m_nAge;
      string m_strName;
      };

      class Student:public Person{
      public:
      Student(int Age,string Name,int Num):Person(Age,Name),m_nNum(Num){//初始化列表传入参数,调用构造
      cout<<"Student:子类的代参构造函数"<<endl;
      }
      Student(const Student&stu):Person(stu),m_nNum(stu.m_nNum){//初始化列表传入stu,调用拷贝构造
      cout<<"Student:子类的拷贝构造函数"<<endl;
      }
      Student& operator= (const Student&stu){ // 子类的拷贝赋值
      if(this != &stu)
      {
      Person::operator= (stu);//显式调用基类的operator
      m_nNum = stu.m_nNum;
      }
      return *this;
      }
      private:
      int m_nNum;
      ————————————————
      原文链接:https://blog.csdn.net/whh_1218/article/details/8442734
  • 构造函数和析构函数调用次序:

    • 先构造基类
    • 再构造成员
    • 最后构造自身(调用构造函数)
  • 基类构造顺序由派生层次决定:最远的基类最先构造 成员构造顺序和定义顺序符合 析构函数的析构顺序与构造相反

继承访问权限

基类中protected的成员:

  • 类内部:可以访问; 类的使用者:不能访问; 类的派生类成员:可以访问(protected相比private就是能给派生类访问)
  • 派生类不可访问基类的private成员,可访问基类的protected成员,可访问基类的public成员

公有(public)继承

在派生类中,基类成员在派生类的权限为:

  • public -> public
  • protected -> protected
  • private -> 不可访问

私有(private)继承

在派生类中,基类成员在派生类的权限为:

  • public -> private
  • protected -> private
  • private -> 不可访问

保护(protected)继承

派生方式为protected的继承称为保护继承,在这种继承方式下, 基类的public成员在派生类中会变成protected成员, 基类的protected和private成员在派生类中保持原来的访问权限。注意点:当采用保护继承的时候,由于public成员变为protected成员,因此类的使用者不可访问,而派生类可访问。

在派生类中,基类成员在派生类的权限为

  • public -> protected
  • protected -> protected
  • private -> 不可访问

派生类对基类成员的访问形式

  • 通过派生类对象直接访问基类成员
  • 在派生类成员函数中直接访问基类成员
  • 通过基类名字限定访问被重载的基类成员名

虚拟继承

虚拟继承是多重继承中特有的概念。虚拟基类是为解决多重继承而出现的。如:类D继承自类B1、B2,而类B1、B2都继 承自类A,因此在类D中两次出现类A中的变量和函数。为了节省内存空间,可以将B1、B2对A的继承定义为虚拟继承,而A就成了虚拟基类。实现的代码如下:

1
2
3
4
5
6
7
class A

class B1:public virtual A;

class B2:public virtual A;

class D:public B1,public B2;

虚拟继承在一般的应用中很少用到,所以也往往被忽视,这也主要是因为在C++中,多重继承是不推荐的,也并不常用,而一旦离开了多重继承,虚拟继承就完全失去了存在的必要因为这样只会降低效率和占用更多的空间。

为什么需要虚继承?

由于C++支持多重继承,那么在这种情况下会出现重复的基类这种情况,也就是说可能出现将一个类两次作为基类的可能性。比如这里D继承B1和B2,B1继承A,B2也继承A,实际上有两条继承路径:D->B1->A,以及D->B2->A,D是一样的,但这两个A在直接继承的情况下是不一样的。当存在歧义的时候就会导致编译错误:

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
38
39
40
41
#include<iostream>
using std::cout;
using std::endl;
//------------------------------------------------//
class Base
{
protected:
int value;
public:
Base()
{
cout<<"in Base"<<endl;
}
};
//------------------------------------------------//
class DerivedA:protected Base
{
public:
DerivedA()
{
cout<<"in DerivedA"<<endl;
}
};
//------------------------------------------------//
class DerivedB: protected Base
{
public:
DerivedB()
{
cout<<"in DerivedB"<<endl;
}
};
//------------------------------------------------//
class MyClass:DerivedA,DerivedB
{
public:
MyClass()
{
cout<<"in MyClass"<<value<<endl;
}
};

这种情况下会造成在MyClass中访问value时出现路径不明确的编译错误,要访问数据,就需要显示地加以限定。变成DerivedA::value或 者DerivedB::value,以消除歧义性。并且,通常情况下,像Base这样的公共基类不应该表示为两个分离的对象,而要解决这种问题就可以用虚 基类加以处理。如果使用虚继承,编译便正常了。

虚继承的特点是,在任何派生类中的virtual基类总用同一个(共享)对象表示。

引入虚继承和直接继承的区别

由于有了间接性和共享性两个特征,所以决定了虚继承体系下的对象在访问时必然会在时间和空间上与一般情况有较大不同:

  • 时间:在通过继承类对象访问虚基类对象中的成员(包括数据成员和函数成员)时,都必须通过某种间接引用来完成,这样会增加引用寻址时间(就和虚函数一样),其实就是调整this指针以指向虚基类对象,只不过这个调整是运行时间接完成的。
  • 空间:由于共享所以不必要在对象内存中保存多份虚基类子对象的拷贝,这样较之多继承会节省空间。虚拟继承与普通继承不同的是,虚拟继承可以防止出现diamond继承时,一个派生类中同时出现了两个基类的子对象。也就是说,为了保证这一点,在虚拟继承情况下,基类子对象的布局是不同于普通继承的。因此,它需要多出一个指向基类子对象的指针

内存考虑

1
2
3
4
5
6
7
8
9
10
第一种情况:           第二种情况:            第三种情况             第四种情况:
class a            class a             class a              class a
{              {                {                  {
virtual void func();   virtual void func();     virtual void func();       virtual void func();
};              };                  char x;               char x;
class b:public virtual a  class b :public a        };                 };
{               {                 class b:public virtual a      class b:public a
virtual void foo();   virtual void foo();     {                 {
};              };                 virtual void foo();        virtual void foo();
                                };                 };

对这四种情况分别求sizeof(a), sizeof(b):

1
2
3
4
第一种:4,12
第二种:4,4
第三种:8,16
第四种:8,8

每个存在虚函数的类都要有一个4字节的指针指向自己的虚函数表,所以每种情况的类a所占的字节数应该是没有什么问题的,那么类b的字节数怎么算呢?“第一种”和“第三种”情况采用的是虚继承,那么这时候就要有这样的一个指针vptr_b_a,这个指针叫虚类指针,指向虚拟基类,也是四个字节;还要包括类a的字节数,所以类b的字节数就求出来了。而“第二种”和“第四种”情况则不包括vptr_b_a这个指针。

注:关于虚函数表的内容,需要结合图片理解,推荐参考虚函数和虚函数表 - Lucky& - 博客园 (cnblogs.com)

构造函数和析构函数的构造规则

构造函数中有默认参数的情况:既可以在类的声明中,也可以在函数定义中声明缺省参数,但不能既在类声明中又在函数定义中同时声明缺省参数。

  • 当具有下述情况之一时,派生类可以不定义构造函数:

    • 基类没有定义任何构造函数

    • 基类具有缺省参数的构造函数

    • 基类具有无参构造函数。

  • 派生类必须定义构造函数的情况:

    • 当基类或成员对象所属类只含有带参数的构造函数时,即使派生类本身没有数据成员要初始化,它也必须定义构造函数,并以构造函数初始化列表的方式向基类和成员对象的构造函数传递参数,以实现基类子对象和成员对象的初始化。
  • 派生类的构造函数只负责直接基类的初始化

C++语言标准有一条规则:如果派生类的基类同时也是另外一个类的派生类,则每个派生类只负责它的直接基类的构造函数调用。 这条规则表明当派生类的直接基类只有带参数的构造函数,但没有默认构造函数时(包括缺省参数和无参构造函数),它必须在构造函数的初始化列表中调用其直接基类的构造函数,并向基类的构造函数传递参数,以实现派生类对象中的基类子对象的初始化。 这条规则有一个例外情况,当派生类存在虚基类时,所有虚基类都由最后的派生类负责初始化。

总结:

  • 当有多个基类时,将按照它们在继承方式中的声明次序调用,与它们在构造函数初始化列表中的次序无关。当基类A本身又是另一个类B的派生类时,则先调用基类B的构造函数,再调用基类A的构造函数。
  • 当有多个对象成员时,将按它们在派生类中的声明次序调用,与它们在构造函数初始化列表中的次序无关。
  • 当构造函数初始化列表中的基类和对象成员的构造函数调用完成之后,才执行派生类构造函数体中的程序代码。

虚函数与抽象类

虚函数表位于只读数据段(.rodata),也就是C++内存模型中的常量区;而虚函数则位于代码段(.text),也就是C++内存模型中的代码区。

类的虚表会被这个类的所有对象所共享。类的对象可以有很多,但是他们的虚表指针都指向同一个虚表

多态性

多态就是在同一个类或继承体系结构的基类与派生类中,用同名函数来实现各种不同的功能。

  • 静态绑定又称静态联编:是指在编译程序时就根据调用函数提供的信息,把它所对应的具体函数确定下来,即在编译时就把调用函数名与具体函数绑定在一起。
  • 动态绑定又称动态联编:是指在编译程序时还不能确定函数调用所对应的具体函数,只有在程序运行过程中才能够确定函数调用所对应的具体函数,即在程序运行时才把调用函数名与具体函数绑定在一起。
  • 编译时多态性(静态联编(连接)):系统在编译时就决定如何实现某一动作,即对某一消息如何处理。静态联编具有执行速度快的优点。在C++中的编译时多态性是通过函数重载和运算符重载实现的。
  • 运行时多态性(动态联编(连接)):系统在运行时动态实现某一动作,即对某一消息在运行过程实现其如何响应。动态联编为系统提供了灵活和高度问题抽象的优点,在C++中的运行时多态性是通过继承和虚函数实现的。

虚函数

虚函数的意义:

  • 首先派生类对象可以赋值给基类对象。 派生类对象的地址可以赋值给指向基类对象的指针。 派生类对象可以作为基类对象的引用。 赋值相容的问题: 不论哪种赋值方式,都只能通过基类对象(或基类对象的指针或引用)访问到派生类对象从基类中继承到的成员不能借此访问派生类定义的成员
  • 虚函数使得可以通过基类对象的指针或引用访问派生类定义的成员
  • virtual关键字其实质是告知编译系统,被指定为virtual的函数采用动态联编的形式编译。
  • 虚函数的虚特征:基类指针指向派生类的对象时,通过该指针访问其虚函数将调用派生类的版本

要点:

  • 一旦将某个成员函数声明为虚函数后,它在继承体系中就永远为虚函数了。(派生类在定义时可以不加virtual关键字)
  • 如果基类定义了虚函数,当通过基类指针或引用调用派生类对象时,将访问到它们实际所指对象中的虚函数版本
  • 只有通过基类对象的指针和引用访问派生类对象的虚函数时,才能体现虚函数的特性
  • 派生类中的虚函数要保持其虚特征,必须与基类虚函数的函数原型完全相同否则就是普通的重载函数,与基类的虚函数无关。
  • 派生类通过从基类继承的成员函数调用虚函数时,将访问到派生类中的版本
  • 只有类的非静态成员函数才能被定义为虚函数,类的构造函数和静态成员函数不能定义为虚函数。原因是虚函数在继承层次结构中才能够发生作用,而构造函数、静态成员是不能够被继承的
  • 内联函数也不能是虚函数(多态时)。因为内联函数采用的是静态联编的方式,而虚函数是在程序运行时才与具体函数动态绑定的,采用的是动态联编的方式,即使虚函数在类体内被定义,C++编译器也将它视为非内联函数
    • 类中定义的函数是内联函数,类中声明、类外定义且都没用inline关键字的是普通函数(注:inline要起作用,inline要与函数定义放在一起。inline是一种“用于实现的关键字,而不是用于声明的关键字)
    • 虚函数可以是内联函数,内联是可以修饰虚函数的,但是当虚函数表现多态性的时候不能内联。
    • 内联是在编译期建议编译器内联,而虚函数的多态性在运行期,编译器无法知道运行期调用哪个代码,因此虚函数表现为多态性时(运行期)不可以内联。
    • inline virtual 唯一可以内联的时候是:编译器知道所调用的对象是哪个类(如 Base::who()),这只有在编译器具有实际对象而不是对象的指针或引用时才会发生。
  • 基类析构函数几乎总是为虚析构函数。假定使用delete和一个指向派生类的基类指针来销毁派生类对象,如果基类析构函数不为虚,就如一个普通成员函数,delete函数调用的就是基类析构函数(而不是调用该派生类的析构函数)。在通过基类对象的引用或指针调用派生类对象时,将致使对象析构不彻底。(如果是虚析构函数,则会先调用派生类的析构函数再调用基类的析构函数)

纯虚函数和抽象类

纯虚函数:仅定义函数原型而不定义其实现的虚函数。

实用角度:占位手段place-holder。

方法学:接口定义手段,抽象表达手段。

1
2
3
4
class X 
{
virtual ret_type func_name (param) = 0; //virtual与‘=0’就构成纯虚函数
}

抽象类:包含一个或多个纯虚函数的类。

不能实例化抽象类,但可以定义抽象类的指针和引用。定义一个抽象类的派生类,必须定义所有纯虚函数,否则该派生类仍然是一个抽象类。

总结:

  • 抽象类中含有纯虚函数,由于纯虚函数没有实现代码,所以不能建立抽象类的对象。
  • 抽象类只能作为其他类的基类,可以通过抽象类对象的指针或引用访问到它的派生类对象,实现运行时的多态性。
  • 如果派生类只是简单地继承了抽象类的纯虚函数,而没有重新定义基类的纯虚函数,则派生类也是一个抽象类。

运算符重载

运算符重载是C++的一项强大功能。通过重载,可以扩展C++运算符的功能,使它们能够操作用户自定义的数据类型,增加程序代码的直观性和可读性。

重载二元运算符

二元运算符的调用形式与解析:a@b 可解释成 a.operator@(b) 或解释成 operator@(a,b)(@表示运算符)

如果两者都有定义,就按照重载解析:

1
2
3
4
5
6
7
class X{
public:
void operator+(int);//实际上是X+int
X(int);
};
void operator+(X,X);//X+x
void operator+(X,double);//X+double

类运算符重载形式

  • 非静态成员运算符重载:以类成员形式重载的运算符参数比实际参数少一个,第1个参数是以this指针隐式传递的。

    1
    2
    3
    4
    5
    6
    class Complex{
    double real,image;
    public:
    Complex operator+(Complex b){……}//实际上是Complex+Complex,*this+b
    ......
    };
  • 友元运算符重载:如果将运算符函数作为类的友元重载,它需要的参数个数就与运算符实际需要的参数个数相同。

1
2
3
4
5
6
7
class Complex{
……
friend Complex operator+(Complex a,Complex b);//声明
//......
};
//实际上就是Complex+Complex,a+b
Complex operator+(Complex a,Complex b){……}//定义,注意定义必须在类声明完成后实现,否则编译器不知道Complex是一个类的类型

重载一元运算符

  • 一元运算符:只需要一个运算参数,如取地址运算符(&)、负数(?)、自增加(++)等。

  • 一元运算符常见调用形式如下,其中的@代表一元运算符,a代表操作数。

    • 隐式调用形式:@a 或 a@ ,@a代表前缀一元运算,如“++a”;a@表示后缀运算,如“a++”。
    • 显式调用一元运算符@:a.operator@()
  • 一元运算符作为类成员函数重载时不需要参数,其形式如下:

    1
    2
    3
    4
    5
    6
    class X{
    ……
    T operator@(){……};
    }
    //T是运算符@的返回类型。从形式上看,作为类成员函数重载的一元运算符没有参数
    //但实际上它包含了一个隐含参数,即调用对象的this指针。
  • 前自增(减)与后自增(减):C++编译器可以通过在运算符函数参数表中是否插入关键字int 来区分这两种方式

    1
    2
    3
    4
    5
    6
    //前缀
    operator ++ ();
    operator ++ (X & x);//这里的X代表其他的类型,可能有其他操作
    //后缀
    operator ++ (int);
    operator ++ (X & x, int);

重载赋值运算符=

当以拷贝的方式初始化一个对象时,会调用拷贝构造函数;当给一个对象赋值时,会调用重载过的赋值运算符。

即使我们没有显式的重载赋值运算符,编译器也会以默认地方式重载它。默认重载的赋值运算符功能很简单,就是将原有对象的所有成员变量一一赋值给新对象,这和默认拷贝构造函数的功能类似。

对于简单的类,默认的赋值运算符一般就够用了,我们也没有必要再显式地重载它。但是当类持有其它资源时,例如动态分配的内存、打开的文件、指向其他数据的指针、网络连接等,默认的赋值运算符就不能处理了,我们必须显式地重载它,这样才能将原有对象的所有数据都赋值给新对象。

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
38
#include <iostream>
#include <cstdlib>
using namespace std;
//变长数组类
class Array{
public:
Array(int len);
Array(const Array &arr); //拷贝构造函数
~Array();
public:
int operator[](int i) const { return m_p[i]; } //获取元素(读取)
int &operator[](int i){ return m_p[i]; } //获取元素(写入)
Array & operator=(const Array &arr); //重载赋值运算符
int length() const { return m_len; }
private:
int m_len;
int *m_p;
};
Array::Array(int len): m_len(len){
m_p = (int*)calloc( len, sizeof(int) );
}
Array::Array(const Array &arr){ //拷贝构造函数
this->m_len = arr.m_len;
this->m_p = (int*)calloc( this->m_len, sizeof(int) );
memcpy( this->m_p, arr.m_p, m_len * sizeof(int) );
}
Array::~Array(){ free(m_p); }

Array &Array::operator=(const Array &arr){ //重载赋值运算符
if( this != &arr){ //判断是否是给自己赋值
this->m_len = arr.m_len;
free(this->m_p); //释放原来的内存
this->m_p = (int*)calloc( this->m_len, sizeof(int) );
memcpy( this->m_p, arr.m_p, m_len * sizeof(int) );
}
return *this;
}

  • operator=() 的返回值类型为Array &,这样不但能够避免在返回数据时调用拷贝构造函数,还能够达到连续赋值的目的。下面的语句就是连续赋值:arr4 = arr3 = arr2 = arr1;
  • if( this != &arr)语句的作用是「判断是否是给同一个对象赋值」:如果是,那就什么也不做;如果不是,那就将原有对象的所有成员变量一一赋值给新对象,并为新对象重新分配内存。
  • return *this表示返回当前对象(新对象)。
  • operator=() 的形参类型为const Array &,这样不但能够避免在传参时调用拷贝构造函数,还能够同时接收 const 类型和非 const 类型的实参
  • 赋值运算符重载函数除了能有对象引用这样的参数之外,也能有其它参数。但是其它参数必须给出默认值,例如:Array & operator=(const Array &arr, int a = 100);

重载赋值运算符[]

  • [ ]是一个二元运算符,其重载形式如下:

    1
    2
    3
    4
    class X{
    ……
    X& operator[](int n);//调用:X[n]
    };
  • 重载[]需要注意的问题

    • []是一个二元运算符,其第1个参数是通过对象的this指针传递的,第2个参数代表数组的下标
    • 由于[]既可以出现在赋值符“=”的左边,也可以出现在赋值符“=”的右边,所以重载运算符[]时常返回引用。(既能作为左值赋值也能作为右值读取,如果不是引用,作为左值时函数只是返回了一个临时对象,赋值写入没用意义)
    • []只能被重载为类的非静态成员函数,不能被重载为友元和普通函数

重载( )

  • 运算符( )是函数调用运算符,也能被重载。且只能被重载为类的成员函数

  • 运算符( )的重载形式如下:

    1
    2
    3
    4
    class X{
    ……
    X& operator( )(参数表);//其中的参数表可以包括任意多个参数。
    };
  • 运算符( )的调用形式如下:

    1
    2
    3
    4
    5
    X Obj; //对象定义

    Obj()(参数表); //调用形式1

    Obj(参数表); //调用形式2,普遍用这种形式

类强制转换的重载

  • 类型转换函数没有参数
  • 类型转换函数没有返回类型
  • 类型转换函数必须返回将要转换成的type类型数据

看一下实例便知:

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
/*
有一个类Circle,设计该类的类型转换函数,当将Circle对象转换成int型时,返回圆的半径;当将它转换成double型时,就返回圆的周长;当将它转换成float型时,就返回圆的面积。
*/


#include <iostream>
using namespace std;
class Circle{
private:
double x,y,r;
public:
Circle(double x1,double y1,double r1){x=x1;y=y1;r=r1; }
operator int(){return int(r);}
operator double(){return 2*3.14*r;}
operator float(){return (float)3.14*r*r;}
};
int main(){
Circle c(2.3,3.4,2.5);
int r=c; //因为r是int类型,会导致c主动调用operator int(),将Circle类型转换成int,然后给r赋值。
double length=c; //调用operator double(),转换成double
float area=c; //调用operator float(),将Circle类型转换成float
double len=(double) c; //将Cirlce类型对象强制转换成double
cout<<r<<endl;
cout<<length<<endl;
cout<<len<<endl;
cout<<area<<endl;
return 0;
}

/*
输出结果:
2
15.7
15.7
19.625
*/

模板

模板(template)是C++实现代码重用机制的重要工具,是泛型技术(即与数据类型无关的通用程序设计技术)的基础。 模板是C++中相对较新的语言机制,它实现了与具体数据类型无关的通用算法程序设计,能够提高软件开发的效率,是程序代码复用的强有力工具。

模板概念:模板是对具有相同特性的函数或类的再抽象,模板是一种参数多态性的工具,可以为逻辑功能相同而类型不同的程序提供一种代码共享的机制。 一个模板并非一个实实在在的函数或类,仅仅是一个函数或类的描述,是参数化的函数和类。

函数模板

函数模板提供了一种通用的函数行为,该函数行为可以用多种不同的数据类型进行调用,编译器会据调用类型自动将它实例化为具体数据类型的函数代码,也就是说函数模板代表了一个函数家族。 与普通函数相比,函数模板中某些函数元素的数据类型是未确定的,这些元素的类型将在使用时被参数化;与重载函数相比,函数模板不需要程序员重复编写函数代码,它可以自动生成许多功能相同但参数和返回值类型不同的函数。当实例化一个函数模板时,编译器自动生成一份具有相应类型的代码。

函数模板的定义:

1
2
3
4
5
template <class T1, class T2,…>
返回类型 函数名(参数表)
{
…… //函数模板定义体
}

template是定义模板的关键字;写在一对<>中的T1,T2,…是模板参数,其中的class表示其后的参数可以是任意类型。

注意事项 :

  • 在定义模板时,不允许template语句与函数模板定义之间有任何其他语句。

    1
    2
    3
    template <class T>
    int x; //错误,不允许在此位置有任何语句
    T min(T a,T b){…}
  • 函数模板可以有多个类型参数,但每个类型参数都必须用关键字class或typename限定。此外,模板参数中还可以出现确定类型参数,称为非类型参数(浮点数和类对象是不允许作为非类型模板参数的)。例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    template <class T1,class T2,class T3,int T4>//在传递实参时,非类型参数T4只能使用常量
    T1 fx(T1 a, T 2 b, T3 c){…}

    //如:
    template<int Val, typename T>
    T addValue(T x)
    {
    return x + Val;
    }

    //调用,这种情况必须像类一样显式实例化
    addValue<5,int>(1);
  • 不要把这里的class与类的声明关键字class混淆在一起,虽然它们由相同的字母组成,但含义是不同的。这里的class表示T是一个类型参数,可以是任何数据类型,如int、float、char等,或者用户定义的struct、enum或class等自定义数据类型。

  • 为了区别类与模板参数中的类型关键字class,标准C++用typename作为模板参数的类型关键字,同时也支持使用class。比如,把min定义的template 写成下面的形式是完全等价的:

    1
    2
    template <typename T> 
    T min(T a,T b){…}

函数模板的实例化:

  • 实例化发生的时机 模板实例化发生在调用模板函数时。当编译器遇到程序中对函数模板的调用时,它才会根据调用语句中实参的具体类型,确定模板参数的数据类型,并用此类型替换函数模板中的模板参数,生成能够处理该类型的函数代码,即模板函数

  • 当多次发生类型相同的参数调用时,只在第1次进行实例化。假设有下面的函数调用:

    1
    2
    3
    4
    5
    6
    int x=min(2,3);     
    int y=min(3,9);
    int z=min(8.5);

    //编译器只在第1次调用时生成模板函数,当之后遇到相同类型的参数调用时,不再生成其他模板函数,
    //它将调用第1次实例化生成的模板函数。

实例化方式:

  • 隐式实例化:编译器能够判断模板参数类型时,自动实例化函数模板为模板函数

    1
    2
    3
    4
    5
    6
    7
    8
    //隐式实例化,表面上是在调用模板,实际上是调用其实例
    template <typename T>
    T max (T, T);

    int i = max (1, 2);
    float f = max (1.0, 2.0);
    char ch = max (‘a’, ‘A’);

  • 显示实例化explicit instantiation :编译器不能判断模板参数类型或常量值,需要使用特定数据类型实例化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    //语法形式:模板名称<数据类型,…,常量值,…> (参数)

    template <class T>
    T max (T, T);

    int i = max (1, ‘2’); // error: data type can’t be deduced

    int i = max<int> (1, ‘2’);//将'2‘强制转化为int类型

函数模板的特化(函数模板的特化,只能全特化):

  • 特化的原因:在某些情况下,模板描述的通用算法不适合特定的场合(数据类型等) 比如:如max函数

    1
    2
    3
    char * cp = max (“abcd”, “1234”);
    实例化为:
    char * max (char * a, char * b){return a > b ? a : b;}

    这肯定是有问题的,因为字符串的比较为:

    1
    2
    char * max (char * a, char * b)
    { return strcmp(a, b)>0 ? a : b; }

    因此需要写出一份特化版本的max函数,在遇到字符串时使用特化版本而不使用泛型版本。

  • 所谓特化,就是针对模板不能处理的特殊数据类型,编写与模板同名的特殊函数专门处理这些数据类型。

    模板特化的定义形式template <> 返回类型 函数名<特化的数据类型>(参数表) { …… }

    说明: ① template < >是模板特化的关键字,< >中不需要任何内容; ② 函数名后的< >中是需要特化处理的数据类型,实际上,这是对泛型版本说明该函数要特化的形式(即显式告知泛型中的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
25
26
27
28
29
//泛型版本
template <class T> int compare(const T &v1, const T &v2)
{
if(v1 < v2) return -1;
if(v2 > v1) return 1;
return 0;
}

//对于该函数模板,当实参为两个char指针时,比较的是指针的大小,而不是指针指向内容的大小,
//此时就需要为该函数模板定义一个特化版本,即特殊处理的版本:


//为实参类型 const char * 提供特化版本
//template <> int compare(const char * const &v1, const char * const &v2)
//省略了函数名后边的显示模板实参,因为可以从函数形参表推断出来,本定义与下边的定义都是正确的;
template <> int compare<const char *>(const char * const &v1, const char * const &v2)
{
std::cout << "template <> int compare<const char *>" << std::endl;
return strcmp(v1, v2);
}

//为实参类型 char * 提供特化版本
//template <> int compare(char * const &v1, char * const &v2)
template <> int compare<char *>(char * const &v1, char * const &v2)
{
std::cout << "template <> int compare<char *>" << std::endl;
return strcmp(v1, v2);
}

  • 当程序中同时存在模板和它的特化时,特化将被优先调用;
  • 在同一个程序中,除了函数模板和它的特化外,还可以有同名的普通函数。其区别在于C++会对普通函数的调用实参进行隐式的类型转换,但不会对模板函数及特化函数的参数进行任何形式的类型转换(需要匹配或者显式实例)。
  • 当同一程序中具有模板与普通函数时,其匹配顺序(调用顺序)如下:
    • 1.完全匹配的非模板函数
    • 2.完全匹配的模板函数
    • 3.类型相容的非模板函数

类模板

类模板可用来设计结构和成员函数完全相同,但所处理的数据类型不同的通用类。

类模板的声明:

1
2
3
4
5
template<class T1,class T2,…>
class 类名
{
…… // 类成员的声明与定义
}

其中T1、T2是类型参数。类模板中可以有多个模板参数,包括类型参数和非类型参数

非类型参数是指某种具体的数据类型,在调用模板时只能为其提供用相应类型的常数值。非类型参数是受限制的,通常可以是整型、枚举型、对象或函数的引用,以及对象、函数或类成员的指针,但不允许用浮点型(或双精度型)、类对象或void作为非类型参数

1
2
template<class T1,class T2,int T3>
//在实例化时,必须为T1、T2提供一种数据类型,为T3指定一个整常数(如10),该模板才能被正确地实例化。

类模板的成员函数的定义:

  • 类内成员函数定义,与常规成员函数的定义类似,另外 “模板参数列表”引入的“类型标识符”直接作为数据类型使用,“模板参数列表”引入的“普通数据类型常量”直接作为常量使用。

  • 在类模板外定义,语法:

    template <模板参数列表> [返回值类型] [类模板名<模板参数名表>::] [成员函数名] ([参数列表]){…};

    就比普通的模板函数多了 [类模板名<模板参数名表>::]

类可以特化,与函数模板不同的是,类不仅可以全特化,也可以偏特化

偏特化是指提供另一份template定义式,而其本身仍为templatized,这是针对于template参数更进一步的条件限制所设计出来的一个特化版本。也就是如果这个模板有多个类型,那么只限定其中的一部分;

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
#include <iostream>
using namespace std;

template<class T1,class T2>//泛型
class Test
{
public:
Test(T1 a, T2 b):_a(a),_b(b)
{
cout << "模板化" << endl;
}
private:
T1 _a;
T2 _b;
};

//模板全特化
template<>
class Test<int,int>
{
public:
Test(int a, int b) :_a(a), _b(b)
{
cout << "模板全特化" << endl;
}
private:
int _a;
int _b;
};

//模板偏特化,只限定一个参数类型
template<class T>
class Test<int,T>
{
public:
Test(int a, T b) :_a(a), _b(b)
{
cout << "模板偏特化" << endl;
}
private:
int _a;
T _b;
};

int main()
{
Test<double, double> t1(1.01, 1.01);
Test<int, int> t2(1, 1);
Test<int, char*> t3(1, "111");
return 0;
}

//类模板成员函数的全特化(不能偏特化)
//实际上也是对类模板的特化,因为作为类的成员函数,当类的类型确定下来了自然就知道要调用哪个类型的函数
#include<iostream>
#include<cstring>
using namespace std;
#define MAXSIZE 5
template<class T>
class Array
{
public:
Array(){
for(int i=0;i<MAXSIZE;i++){
array[i]=0;
}
}
void Sort();//特化
private:
T array[MAXSIZE];
};
template<class T> //泛型
void Array<T>::Sort(){
cout<<"fanxing"<<endl;

int p,j;
for(int i=0;i<MAXSIZE-1;i++){
p=i;
for(j=i+1;j<MAXSIZE;j++){
if(array[p]<array[j])
p=j;
}
T t;
t=array[i];
array[i]=array[p];
array[p]=t;
}
}
template<> //特化
void Array<char *>::Sort(){ //注意是对Array类模板特化
cout<<"tehua"<<endl;
int p,j;
for(int i=0;i<MAXSIZE-1;i++){
p=i;
for(j=i+1;j<MAXSIZE;j++){
if(strcmp(array[p],array[j])<0)
p=j;
}
char* t=array[i];
array[i]=array[p];
array[p]=t;
}
}

强调:

  • 函数模板只有特化,没有偏特化;
  • 模板、模板的特化和模板的偏特化都存在的情况下,编译器在编译阶段进行匹配,优先匹配特殊的(如能匹配全特化就不会匹配偏特化);
  • 模板函数不能是虚函数;因为每个包含虚函数的类具有一个virtual table,包含该类的所有虚函数的地址,因此vtable的大小是确定的。模板只有被使用时才会被实例化,将其声明为虚函数会使vtable的大小不确定(函数模板可能用到,可能用不到)。所以,成员函数模板不能为虚函数。
    • 编译器在编译一个类的时候,需要确定这个类的虚函数表的大小。一般来说,如果一个类有N个虚函数,它的虚函数表的大小就是N,如果按字节算的话那么就是4*N。 如果允许一个成员模板函数为虚函数的话,因为我们可以为该成员模板函数实例化出很多不同的版本,也就是可以实例化出很多不同版本的虚函数
    • 那么编译器为了确定类的虚函数表的大小,就必须要知道我们一共为该成员模板函数实例化了多少个不同版本的虚函数。显然编译器需要查找所有的代码文件,才能够知道到底有几个虚函数,这对于多文件的项目来说,代价是非常高的,所以才规定成员模板函数不能够为虚函数。

异常处理

异常是程序在执行期间产生的问题。C++ 异常是指在程序运行时发生的特殊情况,比如尝试除以零的操作。

提供异常基本目的就是为了处理上面的问题。基本思想是:让一个函数在发现了自己无法处理的错误时抛出(throw)一个异常,然后它的(直接或者间接)调用者能够处理这个问题。也就是《C++ primer》中说的:将问题检测问题处理相分离。

优点有以下几点:

  • 异常处理可以在调用跳级。这是一个代码编写时的问题:假设在有多个函数的调用栈中出现了某个错误,使用整型返回码要求你在每一级函数中都要进行处理。而使用异常处理的栈展开机制,只需要在一处进行处理就可以了,不需要每级函数都处理。
    • 栈展开:如果在一个函数内部抛出异常(throw),而此异常并未在该函数内部被捕捉(catch),就将导致该函数的运行在抛出异常处结束,所有已经分配在上的局部变量都要被释放。然后会接着向下线性的搜索函数调用栈,来寻找异常处理者,并且带有异常处理的函数(也就是有catch捕捉到)之前的所有实体(每级函数),都会从函数调用栈中删除。
    • 栈展开危害:在栈展开的过程中,如果被释放的局部变量中有指针,而该指针在此前已经用new运算申请了空间,就有可能导致内存泄露。因为栈展开的时候并不会自动对指针变量执行delete(或delete[])操作。
  • 整型返回值没有任何语义信息。而异常却包含语义信息,有时你从类名就能够体现出来。
  • 整型返回值缺乏相关的上下文信息。异常作为一个类,可以拥有自己的成员,这些成员就可以传递足够的信息。

异常提供了一种转移程序控制权的方式。C++ 异常处理涉及到三个关键字:try、catch、throw

  • throw: 当问题出现时,程序会抛出一个异常。这是通过使用 throw 关键字来完成的。
  • catch: 在您想要处理问题的地方,通过异常处理程序捕获异常catch 关键字用于捕获异常。
  • try: try 块中的代码标识将被激活的特定异常。它后面通常跟着一个或多个 catch 块。

如果有一个块抛出一个异常,捕获异常的方法会使用 trycatch 关键字。try 块中放置可能抛出异常的代码,try 块中的代码被称为保护代码。使用 try/catch 语句的语法如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//如果 try 块在不同的情境下会抛出不同的异常,
//这个时候可以尝试罗列多个 catch 语句,用于捕获不同类型的异常。
try
{
// 保护代码
}catch( ExceptionName e1 )
{
// catch 块
}catch( ExceptionName e2 )
{
// catch 块
}catch( ExceptionName eN )
{
// catch 块
}

抛出异常

可以使用 throw 语句在代码块中的任何地方抛出异常。throw 语句的操作数可以是任意的表达式,表达式的结果的类型决定了抛出的异常的类型。

以下是尝试除以零时抛出异常的实例:

1
2
3
4
5
6
7
8
double division(int a, int b)
{
if( b == 0 )
{
throw "Division by zero condition!";
}
return (a/b);
}

异常被抛出后,从进入try块起,到异常被抛掷前,这期间在栈上构造的所有对象,都会被自动析构。

析构的顺序与构造的顺序相反,这一过程称为栈的解旋(unwinding).

  • 在try中抛出的异常被相应的catch捕获

  • 在catch中抛出的异常可以被上层函数调用的catch捕获

    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
    #include<iostream>
    using namespace std;
    void fun(int x)
    {
    try
    {
    if(x == 0)
    throw "yichang";
    }
    catch(...)
    {
    cout << "in fun" << endl;
    throw 1; // throw to main
    }
    }

    int main()
    {
    try
    {
    fun(0);
    }
    catch(int n)
    {
    cout << "in main, get it" <<endl;
    }
    return 0;
    }

捕获异常

catch 块跟在 try 块后面,用于捕获异常。您可以指定想要捕捉的异常类型,这是由 catch 关键字后的括号内的异常声明决定的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
try
{
// 保护代码
}catch( ExceptionName e )
{
// 处理 ExceptionName 异常的代码
}
//上面的代码会捕获一个类型为 ExceptionName 的异常。

//如果想让 catch 块能够处理 try 块抛出的任何类型的异常,则必须在异常声明的括号内使用省略号 ...,如下所示:
try
{
// 保护代码
}catch(...)
{
// 能处理任何异常的代码
}
  1. catch的匹配过程是找最先匹配的,不是最佳匹配。
  2. catch的匹配过程中,对类型的要求比较严格允许标准算术转换类类型的转换。(类类型的转化包括种:通过构造函数的隐式类型转化和通过转化操作符的类型转化)。

实例

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
#include <iostream>
using namespace std;

double division(int a, int b)
{
if( b == 0 )
{
throw "Division by zero condition!";//被throw的是const char* 类型的
}
return (a/b);
}

int main ()
{
int x = 50;
int y = 0;
double z = 0;

try {
z = division(x, y);
cout << z << endl;
}catch (const char* msg) {
cerr << msg << endl;
}

return 0;
}

由于我们抛出了一个类型为 const char* 的异常,因此,当捕获该异常时,我们必须在 catch 块中使用 const char*。当上面的代码被编译和执行时,它会产生下列结果:Division by zero condition!

C++ 标准的异常

C++ 提供了一系列标准的异常,定义在<exception>中,我们可以在程序中使用这些标准的异常。

std::exception 该异常是所有标准 C++ 异常的父类。
std::bad_alloc 该异常可以通过 new 抛出。
std::bad_cast 该异常可以通过 dynamic_cast 抛出。
std::bad_exception 这在处理 C++ 程序中无法预期的异常时非常有用。
std::bad_typeid 该异常可以通过 typeid 抛出。
std::logic_error 理论上可以通过读取代码来检测到的异常。
std::domain_error 当使用了一个无效的数学域时,会抛出该异常。
std::invalid_argument 当使用了无效的参数时,会抛出该异常。
std::length_error 当创建了太长的 std::string 时,会抛出该异常。
std::out_of_range 该异常可以通过方法抛出,例如 std::vector 和 std::bitset<>::operator
std::runtime_error 理论上不可以通过读取代码来检测到的异常。
std::overflow_error 当发生数学上溢时,会抛出该异常。
std::range_error 当尝试存储超出范围的值时,会抛出该异常。
std::underflow_error 当发生数学下溢时,会抛出该异常。

定义新的异常

这部分看看就行,比较冷门且不常用。

  • 建议自己的异常类要继承标准异常类。因为C++中可以抛出任何类型的异常,所以我们的异常类可以不继承自标准异常,但是这样可能会导致程序混乱,尤其是当我们多人协同开发时。
  • 当继承标准异常类时,应该重载父类的what函数虚析构函数
  • 因为栈展开的过程中,要复制异常类型,那么要根据你在类中添加的成员考虑是否提供自己的复制构造函数
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
#include <iostream>
#include <exception>
using namespace std;

struct MyException : public exception
{
const char * what () const throw ()
{
return "C++ Exception";
}
};

int main()
{
try
{
throw MyException();
}
catch(MyException& e)
{
std::cout << "MyException caught" << std::endl;
std::cout << e.what() << std::endl;
}
catch(std::exception& e)
{
//其他的错误
}
}

结果:

1
2
MyException caught
C++ Exception

函数声明后面加throw()

C++函数后面加关键字throw(something)限制,是对这个函数的异常安全作出限制;这是一种异常规范,只会出现在声明函数时,表示这个函数可能抛出任何类型的异常。

  • void fun() throw(); //表示fun函数不允许抛出任何异常,即fun函数是异常安全的,一旦异常,将是意想不到的严重错误。
  • void fun() throw(…); //表示fun函数可以抛出任何形式的异常。
  • void fun() throw(exceptionType); // 表示fun函数只能抛出exceptionType类型的异常。

例如:

  • void GetTag() throw(int); // 表示只抛出int类型异常
  • void GetTag() throw(int,char); // 表示抛出int,char类型异常
  • void GetTag() throw(); // 表示不会抛出任何类型异常,一旦异常,将是意想不到的严重错误
  • void GetTag() throw(…); // 表示抛出任何类型异常

void GetTag() throw(int); 表示只抛出int类型异常,并不表示一定会抛出异常,但是一旦抛出异常只会抛出int类型,如果抛出非int类型异常,调用unexsetpion()函数,退出程序。

来自C++之父Bjarne Stroustrup的建议

  • 当局部的控制能够处理时,不要使用异常;
  • 使用“资源分配即初始化”技术去管理资源;
  • 尽量少用try-catch语句块,而是使用“资源分配即初始化”技术。
  • 如果构造函数内发生错误,通过抛出异常来指明。
  • 避免在析构函数中抛出异常。
  • 保持普通程序代码和异常处理代码分开。
  • 小心通过new分配的内存在发生异常时,可能造成内存泄露。
  • 如果一个函数可能抛出某种异常,那么我们调用它时,就要假定它一定会抛出该异常,即要进行处理。
  • 要记住,不是所有的异常都继承自exception类。
  • 编写的供别人调用的程序库,不应该结束程序,而应该通过抛出异常,让调用者决定如何处理(因为调用者必须要处理抛出的异常)。

文件与流

文件流的分类

文件流是以外存文件未输入输出对象的数据流。输出文件流是从内存流向外存文件的数据,输入文件流是从外存文件流向内存的数据。每一个文件流都有一个内存缓冲区与之对应。

C++有三个用于文件操作的文件类:

1
2
3
4
5
ofstream //文件的写操作(输出),主要是从内存写入存储设备(如磁盘),继承了istream类
ifstream //文件的读操作(输入),主要是从存储设备中读取数据到内存,继承了ostream类

fstream //文件的读写操作,对打开的文件可进行读写操作,继承了iostream类,
//这意味着它可以创建文件,向文件写入信息,从文件读取信息。

想要使用文件流对文件进行操作,修必须要先定义它。
定义时须包含头文件#include< fstream >

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

using namespace std; // 声明命名空间

int main(void) {
// 1》
// 声明输出文件流,用于创建文件并向文件写入信息。
ofstream outFile;

// 2》
// 声明输入文件流,用于从文件读取信息。
ifstream inFIle;

// 3》
// 声明输入和输出文件流,且同时具有 ofstream 和 ifstream 两种功能,这意味着它可以创建文件,向文件写入信息,从文件读取信息。
fstream stream;

return 0
}

打开文件

打开文件操作主要是把我们的文件流类对象和一个文件相关联起来,这样这个被打开的文件可以用类对象表示,之后我们对文件流类对象所做的输入和输出操作其实就是对这个文件所做的操作。

1
2
3
4
void open(const char* filename,ios_base::openmode mode);
//参数的含义:
filename:  要打开的文件名
  mode:    要打开文件的方式

其中mode定义在所有IO的基类中:即ios类,它包括如下几种方式:

模式标志 描述
ios::in 读方式打开文件(ifstream对象默认方式)
ios::out 写方式打开文件 (ofstream对象默认)
ios::trunc 如果此文件已经存在, 就会打开文件之前把文件长度设置为0
ios::app 尾部最加方式(在尾部写入)
ios::ate 文件打开后, 定位到文件尾
ios::binary 二进制方式(默认是文本方式)
ios::nocreate 不建立文件,所以文件不存在时打开失败
ios::noreplace 不覆盖文件,所以打开文件时如果文件存在失败

mode参数可以组合起来使用,但是两个参数之间必须要用操作符|隔开,如下

1
2
3
4
5
ofstream out;  //声明一个ofstream对象out
out.open("text.txt",ios::out|ios::app); //往text.txt文件中输入内容,输入方式在文件的末尾追加内容,且不清空原有的内容

//这个声明方式是调用了ofstream有参构造函数,该构造函数会自动调用open函数。
ofstream out("text.txt",ios::out|ios::app)

判断文件是否打开成功

使用is_open()函数进行文件的判断
当成功打开文件返回真(true),失败返回假(false)

1
2
3
4
5
6
7
8
9
fstream stream;

stream.open("demo.txt");
// 判断文件是否打开成功
if (!stream.is_open()) {
cout << "文件打开失败!" << endl;
system("pause");
exit(-1);
}

关闭文件

当我们完成对文件的操作后,需要调用成员函数close来关闭我们的文件流,close函数的作用其实就是清空该类对象在缓存中的内容并且关闭该对象和文件的关联关系,然后该对象可以和其他文件进行关联。

1
2
3
ofstream file;  //声明一个ofstream对象file
file.open("text.txt",ios::out|ios::app);
file.close(); //关闭"text.txt"文件

为了防止一个类对象被销毁后,还和某个文件保留关联关系,所以文件流类的析构函数都会自动调用close函数。

读写文件

文本文件的读写

文本文件的读写很简单:用插入器(<<)向文件输出;用析取器(>>)从文件输入。

1
2
3
4
5
插入器(<<) 向流输出数据。比如说系统有一个默认的标准输出流(cout),一般情况下就是指的显示器,所以,cout<<“Write
Stdout”<<‘n’;就表示把字符串"Write Stdout"和换行字符(‘n’)输出到标准输出流。

析取器(>>)
从流中输入数据。比如说系统有一个默认的标准输入流(cin),一般情况下就是指的键盘,所以,cin>>x;就表示从标准输入流中读取一个指定类型(即变量x的类型)的数据

比如读取写入txt文件,如下

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<fstream>
using namespace std;
int main()
{
fstream f("d:\\test.txt", ios::out);//定义了一个对象f,只写,"d:\\test.txt"文件不存在则创建,存在则清空原内容
f << 1234 << ' ' << 3.14 << 'A' << "How are you"; //写入数据,这是c++一般的写入形式
f.close();//关闭文件以使其重新变为可访问,函数一旦调用,原先的流对象就可以被用来打开其它的文件
f.open("d:\\test.txt",ios::in);//打开文件,只读
int i;
double d;
char c;
char s[20];
f >> i >> d >> c; //读取数据,从左向右读取相应类型的数据,比如int就读1234,因为后面空格截断了(输入中无法包含空格)
//char就读一个'A'。每次读完游标都指向读完的地方,因此能继续读。
f.getline(s, 20);
cout << i << endl; //显示各数据
cout << d << endl; //endl是一种格式,表示输出一个换行符,并刷新此流,ends只输出一个空字符
cout << c << endl;
cout << s << endl;
f.close();
return 0;
}

头文件<iomanip>

控 制 符 作 用
dec 设置整数为十进制
hex 设置整数为十六进制
oct 设置整数为八进制
setbase(n) 设置整数为n进制(n=8,10,16)
setfill(n) 设置字符填充,c可以是字符常或字符变量
setprecision(n) 设置浮点数的有效数字为n位
setw(n) 设置字段宽度为n位
setiosflags(ios::fixed) 设置浮点数以固定的小数位数显示
setiosflags(ios::scientific) 设置浮点数以科学计数法表示
setiosflags(ios::left) 输出左对齐
setiosflags(ios::right) 输出右对齐
setiosflags(ios::skipws) 忽略前导空格
setiosflags(ios::uppercase) 在以科学计数法输出E与十六进制输出X以大写输出,否则小写。
setiosflags(ios::showpos) 输出正数时显示”+”号
setiosflags(ios::showpoint) 强制显示小数点
resetiosflags() 终止已经设置的输出格式状态,在括号中应指定内容
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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
//对一个数操作时,先对输出流(ostream)进行格式化,最后再输出目标。

#include<iostream>
#include<iomanip>
using namespace std;

int main(int argc, char const *argv[])
{
char s[20]="this is a string";
double digit=-36.96656;
cout<<setw(30)<<left<<setfill('*')<<s<<endl;
cout<<dec<<setprecision(4)<<digit<<endl;
cout<<dec<<15<<endl;
//setbase(int x)设置进制后,后面所有操作都是按照这个进制来计算!
cout<<setbase(10)<<15<<endl;
//四舍五入,并保留2位有效数组
float x=6.6937;
cout<<float(int(x*1000+0.5)/1000.0)<<endl;
system("pause");
return 0;
}

/*
输出结果:
this is a string**************
-36.97
15
15
6.694
*/



#include <iostream>
#include <iomanip>
#include <fstream>

int main()
{
// 前缀0表示八进制 前缀0x表示十六进制 不带前缀表示十进制
int a = 123;
double pi = 22.0/7.0;

// setbase(n) 设置整数为n进制(n=8,10,16)
// oct 八进制 dec 十进制 hex 十六进制
// setiosflags(ios::showbase) 显示进制的前缀
// 数值默认十进制显示输出
std::cout << a << std::endl;
std::cout << "oct: " << std::showbase << std::setbase(8) << a << " " << std::oct << a << std::endl;
std::cout << "dec: " << std::showbase << std::setbase(10) << a << " " << std::dec << a << std::endl;
std::cout << "hex: " << std::showbase << std::setbase(16) << a << " " << std::hex << a << std::endl;

// setprecision(n) 设置浮点数的有效数字为n位
// 有效位数默认是6位,即setprecision(6),即小数点前面和小数点后面加起来的位数为6个有效数字(注意会四舍五入)
std::cout << pi << std::endl;
std::cout << std::setprecision(12) << pi << std::endl;

// setfill(n) 设置字符填充,c可以是字符常或字符变量
// setw(n) 设置字段宽度为n位, 若是实际宽度大于被设置的,则setw函数此时失效, 只针对其后的第一个输出项有效
// setiosflags(ios::left) 输出左对齐
// setiosflags(ios::right) 输出右对齐 默认右对齐
std::cout << std::setfill('*') << std::setw(20) << std::setprecision(12) << pi << std::endl;
std::cout << std::setfill('*') << std::setw(20) << std::setprecision(12) << std::right << pi << std::endl;
std::cout << std::setfill('*') << std::setw(20) << std::setprecision(12) << std::left << pi << std::endl;

// setiosflags(ios::fixed) 设置浮点数以固定的小数位数显示
std::cout << std::fixed << std::setprecision(12) << pi << std::endl;

// setiosflags(ios::scientific) 设置浮点数以科学计数法表示 科学计数法输出E与十六进制输出默认是以小写的,要换成大写需添加uppercase
std::cout << std::scientific << std::setprecision(12) << pi << std::endl;
std::cout << std::scientific << std::uppercase << std::setprecision(12) << pi << std::endl;

// resetiosflags() 终止已经设置的输出格式状态,在括号中应指定内容
std::cout << std::setiosflags(std::ios::scientific) << std::setprecision(12) << pi << " " << std::resetiosflags(std::ios::scientific) << pi << std::endl;

system("pause");
return 0;
}


/*
123
oct: 0173 0173
dec: 123 123
hex: 0x7b 0x7b
3.14286
3.14285714286
*******3.14285714286
*******3.14285714286
3.14285714286*******
3.142857142857
3.142857142857e+00
3.142857142857E+00
3.142857142857E+00 3.14285714286
*/

二进制文件的读写

二进制文件的操作需要在打开文件的时候指定打开方式为ios::binary,并且还可以指定为既能输入又能输出的文件,我们通过成员函数 readwrite来读写二进制文件。

1
2
3
4
5
6
7
8
//这里 buffer 是一块内存的地址,用来存储或读出数据。参数size 是一个整数值,表示要从缓存(buffer)中读出或写入的字符数。
istream& read ( char * buffer, streamsize size);
ostream& write (char * buffer, streamsize size);

//调用
fstream f("d:\\test.txt", ios::out|ios::binary);
f.read(buffer,size);//读完指针会到下一个位置,因此下一个read读出的数据是不同的
f.write(buffer,size);

eof()

infile.eof()判断读入文件是否达到文件尾部,若是则返回true。while(!infile.eof())就常常用来判断是否达到文件尾部,注意要在while循环体内不断地read,向下读,否则会死循环,因为eof()本身并不读取数据。

实例

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
#include<iostream>
#include<fstream>
#include<cstring>
#include<vector>
using namespace std;

class person
{
public:
person(){}
person(char *name, char *id, int math, int chinese, int english)
{
strcpy(Name,name);
strcpy(Id,id);
Math = math;
Chinese = chinese;
English = english;
Sum = Math+Chinese+English;
}

void display()
{
cout<<Name<<"\t"<<Id<<"\t"<<Math<<"\t"<<Chinese<<"\t"<<English<<"\t"<<Sum<<endl;
}

private:
char Name[20];
char Id[20];
int Math;
int Chinese;
int English;
int Sum;
};

int main()
{
char ch;
char Name[20],Id[20];
int Math, Chinese, English;

fstream ioFile;
ioFile.open("./ex10_info.dat",ios::out|ios::app);
cout<<"-----------------building students' infomation-----------------------"<<endl;
do
{
cout<<"enter the name: ";
cin>>Name;
cout<<"enter the id: ";
cin>>Id;
cout<<"enter the math score: ";
cin>>Math;
cout<<"enter the chinese score: ";
cin>>Chinese;
cout<<"enter the english score: ";
cin>>English;

//输入好了之后构建一个对象,然后要把对象的地址送给文件的内存缓冲区指针,使得文件可以从地址里读取per的内容(根据sizeof指定,因为地址只是给了初始的位置,必须知道大小)写到文件里。
//&per 不是 char * 类型,因此要进行强制类型转换(都是8个字节),使得能以字符形式读取内容
//sizeof返回值类型为size_t,write函数会获得具体的大小
person per(Name,Id,Math,Chinese,English);
ioFile.write((char*)&per,sizeof(per));//不是写入地址,而是写入地址处的内容,每个per的地址都是一样的(复用了),但是内容不一样
cout<<"continue to enter infomation?(y/n)";
cin>>ch;
}while(ch=='y'||ch=='Y');

ioFile.close();

ioFile.open("./ex10_info.dat",ios::in);
person p;
ioFile.read((char*)&p,sizeof(p));//再从文件中读取对象的内容,给出p的地址把内容送入到地址对应的缓冲区中,这里的每个p的地址是一样的(复用了)
//&p 不是 char * 类型,因此要进行强制类型转换,使得能以字符形式读取内容
vector<person> v;
vector<person>::iterator vt;
while(!ioFile.eof())
{
v.push_back(p);//然后把p放入vector中,
//注意vector实际上是另开了一块空间,把p的内容存入,所以这里就导致每个内容(最后存在的对象)对应的地址都不一样了,因此前面的复用不会引发覆盖
ioFile.read((char*)&p,sizeof(p));
}
ioFile.close();
cout<<"the infomation is:"<<endl;
for(vt=v.begin();vt!=v.end();vt++)
(*vt).display();

return 0;
}

文件位置指针

istreamostream 都提供了用于重新定位文件位置指针的成员函数。这些成员函数包括关于 istream 的 seekg(”seek get”)和关于 ostream 的 seekp(”seek put”)。

seekg 和 seekp 的参数通常是一个长整型。第二个参数可以用于指定查找方向。查找方向可以是 ios::beg(默认的,从流的开头开始定位),也可以是 ios::cur(从流的当前位置开始定位),也可以是 ios::end(从流的末尾开始定位)。

文件位置指针是一个整数值,指定了从文件的起始位置到指针所在位置的字节数。下面是关于定位 “get” 文件位置指针的实例:

1
2
3
4
5
6
7
8
9
10
11
// 定位到 fileObject 的第 n 个字节(假设是 ios::beg)
fileObject.seekg( n );

// 把文件的读指针从 fileObject 当前位置向后移 n 个字节
fileObject.seekg( n, ios::cur );

// 把文件的读指针从 fileObject 末尾往回移 n 个字节
fileObject.seekg( n, ios::end );

// 定位到 fileObject 的末尾
fileObject.seekg( 0, ios::end );

getline、get、gets和put函数

getline

由于流提取运算符(>>)会以空白符分割,所以我们的输入中无法包含空格。而使用getline函数可以指定分隔符,这样就可以读入包含空格的文本了(如:New York)。getline函数定义在头文件中,f.getline()定义在

1
2
3
4
5
6
7
8
9
10
getline(ifstream& input, string s, char delimitChar)//getline接受的字符串长度不受限制
//input是输入的对象,可以是一个文件,也可以是标准输入(cin)
//s是接受字符串,所读取的信息存储在s中
//delimitChar是分隔符,默认是'\n'

getline(cin, city, '\n');
//从标准输入(键盘)读入到字符串city中,并使用换行作为分隔符。

//作为流的成员函数:getline(<字符数组chs>,<读取字符的个数n>,<终止符>)
f.getline(str,10);

当函数读到分隔符或文件末尾时,就会停止。

get

get函数会从输入对象读取一个字符,而put函数会向输出对象写入一个字符。

get函数有三个版本:

1
2
3
4
5
6
7
8
9
10
11
char get() //无参数的,返回从输入对象读取的一个字符。
ifstream* get(char& ch) //有一个参数的,将字符存在字符ch中,并返回输入对象的引用
ifstream* get(char& ch, int n, char delimitChar='\n') //有三个参数的,读取n-1个字符,
//赋给指定的字符数组(或字符指针指向的数组),如果在读取n-1个字符之前遇到指定的终止字符,则提前结束读取

//调用,这里使用cin标准输入流,也可以是其他文件输入流
cin.get();
cin.get(ch);
cin.get(ch,10);//相当于cin.get(ch,10,'\n');
cin.get(ch,10,'x');

用getline函数从输入流读字符时,遇到终止标志字符时结束,指针移到该终止标志字符之后,下一个getline函数将从该终止标志的下一个字符开始接着读入,如本程序运行结果所示那样。如果用cin.get()函数从输入流读字符时,遇终止标志字符时停止读取,指针不向后移动,仍然停留在原位置。下一次读取时仍从该终止标志字符开始。这是getline函数和get函数不同之处。

gets

引入cstdio头文件(#include ),才能进行调用。

1
char *gets(char *str)

从标准输入 stdin 读取一行,并把它存储在 str 所指向的字符串中。当读取到换行符时,或者到达文件末尾时,它会停止,具体视情况而定。

如果成功,该函数返回 str。如果发生错误或者到达文件末尾时还未读取任何字符,则返回 NULL。

1
2
3
4
5
6
7
8
#include<cstdio> //这个头文件包含gets()函数
int main(void)
{
char str1[5];
gets(str1);
printf("%s\n", str1);
return 0;
}

本函数可以无限读取,不会判断上限,所以程序员应该确保 buffer的空间足够大,以便在执行读操作时不发生溢出。如果溢出,多出来的字符将被写入到 堆栈中,这就 覆盖了堆栈原先的内容,破坏一个或多个不相关变量的值。这个事实导致gets函数只适用于玩具程序,为了避免这种情况,我们可以用fgets(stdin)

put

fstream 和 ofstream 类对象都可以调用 put() 方法。

当 fstream 和 ofstream 文件流对象调用 put() 方法时,该方法的功能就变成了向指定文件中写入单个字符。put() 方法的语法格式如下:

1
2
ostream& put (char c);//c 用于指定要写入文件的字符。
//该方法会返回一个调用该方法的对象的引用形式。例如,obj.put() 方法会返回 obj 这个对象的引用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <fstream>
using namespace std;

int main()
{
char c;
//以二进制形式打开文件
ofstream outFile("out.txt", ios::out | ios::binary);

if (!outFile) {
cout << "error" << endl;
return 0;
}
while (cin >> c) {
//将字符 c 写入 out.txt 文件
outFile.put(c);
}
outFile.close();
return 0;
}

cstring库常用函数

字符数组复制

strcpy

strcpy的作用是复制整个字符数组到另一个字符数组,因此也就非常简洁,只有两个参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
//前一个参数是要复制到的目标数组起始位置,后一个是被复制的源数组起始位置。
char * strcpy ( char * destination, const char * source );

//调用
int main () {
char str1[] = "Sample string";
char str2[40];
char str3[40];
strcpy (str2, str1);
strcpy (str3, "copy successful");

return 0;
}

strncpy

strncpystrcpy很类似,只是可以指定复制多少个字符。它的原型是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//前两个参数的含义与strcpy相同,第三个参数num就是要复制的字符个数。
char * strncpy ( char * destination, const char * source, size_t num );
//size_t表示无符号整数,为方便系统移植定义的,32位为unsigned int,64位为unsigned long

//调用
int main () {
char str1[] = "To be or not to be";
char str2[40];
char str3[40];

/* 整个字符串进行复制: */
strncpy ( str2, str1, sizeof(str2) );

/* 部分复制(这里是复制5个字符): */
strncpy ( str3, str2, 5 );
str3[5] = '\0'; /* 部分复制不会自动添加结尾,添加结尾符 */

puts (str1);
puts (str2);
puts (str3);

return 0;
}

字符数组连接

strcat

strcat的功能是把一个字符数组连接到另一个字符数组的后面。它的原型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//前一个是目标数组,后一个是要添加到后面的源数组。
char * strcat ( char * destination, const char * source );

//调用
int main () {
char str[80];
strcpy (str, "these ");
strcat (str, "strings ");
strcat (str, "are ");
strcat (str, "concatenated.");
puts (str);
return 0;
}
//输出结果是:these strings are concatenated.

strncat

指定字符数的拼接,原型是:

1
2
3
4
5
6
7
8
9
10
11
12
char * strncat ( char * destination, const char * source, size_t num );

//调用
int main () {
char str1[20];
char str2[20];
strcpy (str1, "To be ");
strcpy (str2, "or not to be");
strncat (str1, str2, 6);
puts (str1);
return 0;
}

字符数组比较

strcmp

1
2
//比较方式是:(字典序)两个字符串自左向右逐个字符相比(按ASCII值大小相比较),直到出现不同的字符或遇’\0’为止。
int strcmp ( const char * str1, const char * str2 );
  • str1>str2:则返回值>0;
  • str1<str2:则返回值<0;
  • str1=str2:则返回值=0;

strncmp

1
int strncmp ( const char * str1, const char * str2, size_t num )

比较ptr1、ptr2指向的字符串,直到遇到不相同的字符或者空字符结束或者比较完前面的num bytes结束。如果都相同则返回0,如果第一个不同byte ptr1的小于ptr2的,返回负数,否则返回正数。

字符数组查找

strchr

strchr函数可以在一个字符数组里找某个字符第一次出现的位置,如果未找到该字符则返回 NULL。它的原型是:

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
//前一个是原字符数组,后一个是要查找的字符。
const char * strchr ( const char * str, int character );

//调用
int main ()
{
const char str[] = "http://www.runoob.com";
const char ch = '.';
char *ret;

ret = strchr(str, ch);

printf("|%c| 之后的字符串是 - |%s|\n", ch, ret);

return(0);
}

//输出结果:|.| 之后的字符串是 - |.runoob.com|

//以下代码将 st 指针指向的字符串在换行的地方加入文本结束字符,else 将多余的换行符消耗掉:
find = strchr(st, '\n'); //查找换行符
if (find) //如果地址不是NULL
*find = '\0'; //在此处放置一个空字符
else
while (getchar() != '\n')
continue;

strstr

strstr函数可以在一个字符数组里查找另一个字符数组第一次出现的位置。它的原型是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//前一个是文本串,后一个是模式串。
const char * strstr ( const char * str1, const char * str2 );

//调用
int main () {
char str[] = "This is a simple string";
char *pch;
pch = strstr(str, "simple");
strncpy (pch, "sample", 6);
puts (str);
return 0;
}
//指向同一个字符串,但pch的位置在simple的s处,然后替换
//输出结果:This is a sample string

字符数组长度

strlen

strlen用于求一个字符数组的长度,注意它是从给定的起始位置开始不断往后尝试,直到遇到’\0’为止的,因此它的时间复杂度并不是常数级别的,而是取决于字符数组的长度,在字符数组没有变动的情况下请务必不要重复调用

1
2
3
4
5
6
7
8
9
10
11
12
13
size_t strlen ( const char * str );

//调用
int main () {
char szInput[256];
printf ("Enter a sentence: ");
gets (szInput);
printf ("The sentence entered is %u characters long.\n", (unsigned)strlen(szInput));
return 0;
}
//输出结果:
//Enter sentence: just testing
//The sentence entered is 12 characters long.

内存复制

memcpy

从source指向的地址拷贝num bytes到destination指向的地址。不检查source中的空字符,总是拷贝num bytes,可能产生溢出,当destination和source的大小小于num时。

1
void* memcpy(void* destination,const void* source, size_t num)
  • str1 – 指向用于存储复制内容的目标数组,类型强制转换为 void* 指针。
  • str2 – 指向要复制的数据源,类型强制转换为 void* 指针。
  • n – 要被复制的字节数。
  • 该函数返回一个指向目标存储区 str1 的指针。
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
38
39
40
// 将字符串复制到数组 dest 中
int main ()
{
const char src[50] = "http://www.runoob.com";
char dest[50];

memcpy(dest, src, strlen(src)+1);
printf("dest = %s\n", dest);

return(0);
}
//结果:dest = http://www.runoob.com

//将 s 中第 11 个字符开始的 6个连续字符复制到 d 中:
int main()

{
char *s="http://www.runoob.com";
char d[20];
memcpy(d, s+11, 6);// 从第 11 个字符(r)开始复制,连续复制 6 个字符(runoob)
// 或者 memcpy(d, s+11*sizeof(char), 6*sizeof(char));
d[6]='\0';
printf("%s", d);
return 0;
}
//结果:runoob

//覆盖原有部分数据:
int main(void)
{
char src[] = "***";
char dest[] = "abcdefg";
printf("使用 memcpy 前: %s\n", dest);
memcpy(dest, src, strlen(src));
printf("使用 memcpy 后: %s\n", dest);
return 0;
}
//结果:
//使用 memcpy 前: abcdefg
//使用 memcpy 后: ***defg

memmove

从source指向的地址拷贝num bytes到destination指向的地址。常用于同一字符串的改变。不检查source中的空字符,总是拷贝num bytes,可能产生溢出,当destination和source的大小小于num时。

1
void* memmove(void* destination,const void* source, size_t num)

在重叠内存块这方面,memmove() 是比 memcpy() 更安全的方法。如果目标区域和源区域有重叠的话,memmove() 能够保证源串在被覆盖之前将重叠区域的字节拷贝到目标区域中,复制后源区域的内容会被更改。如果目标区域与源区域没有重叠,则和 memcpy() 函数功能相同。

1
2
3
4
5
比如一个字符串123abc456,拷贝123abc到abc456的位置,当顺序覆盖时,abc先被覆盖成了123即123123456,
接下来原本456是要被abc覆盖的,但是abc变成了123,就产生了问题。

memcpy函数会出现上面的问题,因为没有中间变量。
而memmove不会出现问题,使用了一个中间变量先保存好原来的字符串123abc456,这样就不会被覆盖掉正确的值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main ()
{
const char dest[] = "oldstring";
const char src[] = "newstring";

printf("Before memmove dest = %s, src = %s\n", dest, src);
memmove(dest, src, 9);
printf("After memmove dest = %s, src = %s\n", dest, src);

return(0);
}

//Before memmove dest = oldstring, src = newstring
//After memmove dest = newstring, src = newstring

内存比较

memcmp

1
int memcmp ( const void * ptr1, const void * ptr2, size_t num )

比较ptr1、ptr2指向的内存块的前面num bytes,如果都相同则返回0,如果第一个不同byte ptr1的小于ptr2的,返回负数,否则返回正数。如果前面都相同,即使中间遇到空字符,也会继续比较下去,直到比较完所有的num bytes

内存检索

memchr

1
void * memchr ( void * ptr, int value, size_t num );
  • ptr – 指向要执行搜索的内存块。
  • value – 以 int 形式传递的值,但是函数在每次字节搜索时是使用该值的无符号字符形式。
  • num – 要被分析的字节数。

在ptr指向的内存中的前num bytes中搜索值value,返回第一个value的指针,如果没有找到返回空指针。

内存设置

memset

1
2
3
4
5
6
7
8
//设置ptr指向的内存的前面num bytes的值为value
void * memset ( void * ptr, int value, size_t num );

//调用
char str[] = "almost every programmer should know memset!";
memset (str,'-',6);
puts (str);
//输出:------ every programmer should know memset!

cmath库常用函数

数值

std::abs: 计算绝对值,包括整数类型;

std::fabs: 计算绝对值,不包括整数类型;

std::sqrt: 计算平方根;

std::cbrt: 计算立方根;

std::hypot: 计算两个数平方的和的平方根;

std::pow:幂运算;

std::exp: e^x;

std::exp2: 2^x;

std::log: ln(x);

std::log2: log2(x);

std::log10: log10(x);

std::fmod: 两数除法操作的余数(rounded towards zero);

std::remainder: 两数除法操作的余数(rounded to nearest);

std::remquo: 两数除法操作的余数;

取整

std::ceil: 不小于给定值的最近整数;

std::floor: 不大于给定值的最近整数;

std::trunc: 不大于给定值的最近整数;

std::modf: 将一个浮点数分解为整数及小数部分;

std::round: 舍入取整;

std::lround: 舍入取整, 返回long int;

std::llround: 舍入取整, 返回long long int;

角度

std::sin: 正弦;

std::asin: 反正弦;

std::cos: 余弦;

std::acos: 反正弦;

std::tan:正切;

std::atan:反正切;

std::atan2: 反正切;

std::sinh: 双曲正弦;

std::asinh: 双曲反正弦;

std::cosh: 双曲余弦;

std::acosh: 双曲反余弦;

std::tanh: 双曲正切;

std::atanh: 双曲反正切;

检测

std::nan: Generatequiet NaN;

std::isfinite: 检测是否是有限值;

std::isinf: 检测是否是无穷大值;

std::isnan: 检测是否是非数型;

std::isnormal: 检测是否是normal值,neitherinfinity, NaN, zero or subnormal;

std::signbit: 检测是否是负数;

std::isgreater: 检测第一个数是否大于第二个数;

std::isgreaterequal:检测第一个数是否大于或等于第二个数;

std::isless: 检测第一个数是否小于第二个数;

std::islessequal:检测第一个数是否小于或等于第二个数;

std::islessgreater:检测第一个数是否不等于第二个数;

std::isunordered:检测两个浮点数是否是无序的.

其他不常用

std::fma(x,y,z):x*y+z;

std::nearbyint: 使用当前的舍入模式取整(fegetround());

std::rint: 使用当前的舍入模式取整(fegetround());

std::lrint: 使用当前的舍入模式取整(fegetround()),返回long int;

std::llrint: 使用当前的舍入模式取整(fegetround()),返回long longint;

std::frexp: 将一个浮点数分解为有效数(significand)及以2为底的幂(x = significand* 2exp);

std::ldexp: x *2exp;

std::expm1: ex-1;

std::scalbn: x *FLT_RADIXn;

std::scalbln: x* FLT_RADIXn;

std::ilogb: 返回以FLT_RADIX为底,|x|的对数值,返回值为整数;

std::log1p: ln(1+x);

std::logb: 返回以FLT_RADIX为底,|x|的对数值,返回值为浮点数;

std::erf: 误差函数;

std::erfc: 互补(complementary)误差函数;

std::tgamma: 伽玛函数;

std::lgamma: log-伽玛函数;

std::copysign(x,y):返回x的值及y的正负符号组成的浮点数;

std::nextafter(x,y): 返回x之后y方向上的下一个可表示值;

std::nexttoward(x,y): 返回x之后y方向上的下一个可表示值;

std::fdim(x,y): Thefunction returns x-y if x>y, and zero otherwise;

std::fmax: 返回较大的值;

std::fmin: 返回较小的值;

std::fpclassify:为浮点值归类,返回一个类型为int的值;

例子

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
#include "cmath.hpp"
#include <cmath>
#include <iostream>
#include <fenv.h> // fegetround, FE_*
#include <float.h> // FLT_RADIX

// reference: http://www.cplusplus.com/reference/cmath/

#define PI 3.14159265

namespace cmath_ {

int test_cmath_abs()
{
{ // std::abs: double/float/long double/T
std::cout << "abs (3.141611111) = " << std::abs(3.141611111) << '\n'; // 3.14161
std::cout << "abs (-10.6) = " << std::abs(-10.6f) << '\n'; // 10.6
std::cout << "abs ((int)-10) = " << std::abs((int)-10) << '\n'; // 10
}

{ // std::fabs: double/float/long double/T
std::cout << "fabs (3.141611111) = " << std::fabs(3.141611111) << '\n'; // 3.14161
std::cout << "fabs (-10.6) = " << std::fabs(-10.6f) << '\n'; // 10.6
}

{ // std::fma: Returns x*y+z
double x, y, z, result;
x = 10.0, y = 20.0, z = 30.0;

result = std::fma(x, y, z);

printf("10.0 * 20.0 + 30.0 = %f\n", result); // 230.0
}

return 0;
}

int test_cmath_triangle()
{
{ // std::sin: double/float/long double/T
double param, result;
param = 30.0;
result = std::sin(param*PI / 180);
fprintf(stdout, "The sine of %f degrees is %f.\n", param, result); // 0.5
}

{ // std::asin: double/float/long double/T
double param, result;
param = 0.5;
result = std::asin(param) * 180.0 / PI;
fprintf(stdout, "The arc sine of %f is %f degrees\n", param, result); // 30.0
}

{ // std::sinh: double/float/long double/T
double param, result;
param = log(2.0);
result = std::sinh(param);
printf("The hyperbolic sine of %f is %f.\n", param, result); // 0.75
}

{ // std::asinh double/float/long double/T
double param, result;
param = std::exp(2) - std::cosh(2);
result = std::asinh(param);
fprintf(stdout, "The area hyperbolic sine of %f is %f.\n", param, result); // 2.0
}

{ // std::cos double/float/long double/T
double param, result;
param = 60.0;
result = std::cos(param * PI / 180.0);
fprintf(stdout, "The cosine of %f degrees is %f.\n", param, result); // 0.5
}

{// std::acos: double/float/long double/T
double param, result;
param = 0.5;
result = std::acos(param) * 180.0 / PI;
fprintf(stdout, "The arc cosine of %f is %f degrees.\n", param, result); // 60.0
}

{ // std::cosh double/float/long double/T
double param, result;
param = std::log(2.0);
result = std::cosh(param);
fprintf(stdout, "The hyperbolic cosine of %f is %f.\n", param, result); // 1.25
}

{ // std::acosh: double/float/long double/T
double param, result;
param = std::exp(2) - std::sinh(2);
result = std::acosh(param);
fprintf(stdout, "The area hyperbolic cosine of %f is %f radians.\n", param, result); // 2.0
}

{ // std::tan: double/float/long double/T
double param, result;
param = 45.0;
result = std::tan(param * PI / 180.0);
fprintf(stdout, "The tangent of %f degrees is %f.\n", param, result); // 1.0
}

{ // std::atan: double/float/long double/T
double param, result;
param = 1.0;
result = std::atan(param) * 180 / PI;
fprintf(stdout, "The arc tangent of %f is %f degrees\n", param, result); // 45.0
}

{ // std::atan2: double/float/long double/T
double x, y, result;
x = -10.0;
y = 10.0;
result = std::atan2(y, x) * 180 / PI;
fprintf(stdout, "The arc tangent for (x=%f, y=%f) is %f degrees\n", x, y, result); // 135.0
}

{ // std::tanh: double/float/long double/T
double param, result;
param = std::log(2.0);
result = std::tanh(param);
fprintf(stdout, "The hyperbolic tangent of %f is %f.\n", param, result); // 0.6
}

{ // std::atanh: double/float/long double/T
double param, result;
param = std::tanh(1);
result = std::atanh(param);
fprintf(stdout, "The area hyperbolic tangent of %f is %f.\n", param, result); // 1
}

return 0;
}

int test_cmath_pow()
{
{ // std::sqrt(x): Returns the square root of x
double param, result;
param = 1024.0;
result = std::sqrt(param);
printf("sqrt(%f) = %f\n", param, result); // 32.0
}

{ // std::cbrt: Compute cubic root
double param, result;
param = 27.0;
result = std::cbrt(param);
fprintf(stdout, "cbrt (%f) = %f\n", param, result); // 3.0
}

{ // std::hypot(x, y): sqrt(x^2+y^2)
double leg_x, leg_y, result;
leg_x = 3;
leg_y = 4;
result = std::hypot(leg_x, leg_y);
fprintf(stdout, "%f, %f and %f form a right-angled triangle.\n", leg_x, leg_y, result); // 5.0
}

{ // std::pow(x, y): x^y
fprintf(stdout, "7 ^ 3 = %f\n", std::pow(7.0, 3.0)); // 343.0
fprintf(stdout, "4.73 ^ 12 = %f\n", std::pow(4.73, 12.0)); // 125410439.217423
fprintf(stdout, "32.01 ^ 1.54 = %f\n", std::pow(32.01, 1.54)); // 208.036691
fprintf(stdout, "4 ^ 3 = %f\n", std::pow((int)4, (int)3)); // 64.0
}

return 0;
}

int test_cmath_integer()
{
{ // std::ceil(x): returning the smallest integral value that is not less than x
fprintf(stdout, "ceil of 2.3 is %.1f\n", std::ceil(2.3)); // 3.0
fprintf(stdout, "ceil of 3.8 is %.1f\n", std::ceil(3.8)); // 4.0
fprintf(stdout, "ceil of -2.3 is %.1f\n", std::ceil(-2.3)); // -2.0
fprintf(stdout, "ceil of -3.8 is %.1f\n", std::ceil(-3.8)); // -3.0
}

{ // std::floor returning the largest integral value that is not greater than x
fprintf(stdout, "floor of 2.3 is %.1lf\n", std::floor(2.3)); // 2.0
fprintf(stdout, "floor of 3.8 is %.1lf\n", std::floor(3.8)); // 3.0
fprintf(stdout, "floor of -2.3 is %.1lf\n", std::floor(-2.3)); // -2.0
fprintf(stdout, "floor of -3.8 is %.1lf\n", std::floor(-3.8)); // -3.0
}

{ // std::fmod: Returns the floating-point remainder of numer/denom(rounded towards zero)
printf("fmod of 5.3 / 2 is %f\n", std::fmod(5.3, 2)); // fmod of 5.3 / 2 is 1.3
printf("fmod of 18.5 / 4.2 is %f\n", std::fmod(18.5, 4.2)); // fmod of 18.5 / 4.2 is 1.7
}

{ // std::trunc(x): Rounds x toward zero, returning the nearest integral value that is not larger in magnitude than x.
// std::round(x): Returns the integral value that is nearest to x
const char * format = "%.1f \t%.1f \t%.1f \t%.1f \t%.1f\n";
printf("value\tround\tfloor\tceil\ttrunc\n");
printf("-----\t-----\t-----\t----\t-----\n"); // round floor ceil trunc
printf(format, 2.3, std::round(2.3), std::floor(2.3), std::ceil(2.3), std::trunc(2.3)); // 2.0 2.0 3.0 2.0
printf(format, 3.8, std::round(3.8), std::floor(3.8), std::ceil(3.8), std::trunc(3.8)); // 4.0 3.0 4.0 3.0
printf(format, 5.5, std::round(5.5), std::floor(5.5), std::ceil(5.5), std::trunc(5.5)); // 6.0 5.0 6.0 5.0
printf(format, -2.3, std::round(-2.3), std::floor(-2.3), std::ceil(-2.3), std::trunc(-2.3)); // -2.0 -3.0 -2.0 -2.0
printf(format, -3.8, std::round(-3.8), std::floor(-3.8), std::ceil(-3.8), std::trunc(-3.8)); // -4.0 -4.0 -3.0 -3.0
printf(format, -5.5, std::round(-5.5), std::floor(-5.5), std::ceil(-5.5), std::trunc(-5.5)); // -6.0 -6.0 -5.0 -5.0
}

{ // std::lround: Returns the integer value that is nearest in value to x
printf("lround (2.3) = %ld\n", std::lround(2.3)); // 2
printf("lround (3.8) = %ld\n", std::lround(3.8)); // 4
printf("lround (-2.3) = %ld\n", std::lround(-2.3)); // -2
printf("lround (-3.8) = %ld\n", std::lround(-3.8)); // -4
}

{ // std::llround(x): Returns the integer value that is nearest in value to x
printf("llround (2.3) = %lld\n", std::llround(2.3)); // 2
printf("llround (3.8) = %lld\n", std::llround(3.8)); // 4
printf("llround (-2.3) = %lld\n", std::llround(-2.3)); // -2
printf("llround (-3.8) = %lld\n", std::llround(-3.8)); // -4
}

{ // std::nearbyint: Round to nearby integral value
printf("rounding using ");
switch (fegetround()) {
case FE_DOWNWARD: printf("downward"); break;
case FE_TONEAREST: printf("to-nearest"); break; // to-nearest
case FE_TOWARDZERO: printf("toward-zero"); break;
case FE_UPWARD: printf("upward"); break;
default: printf("unknown");
}
printf(" rounding:\n");

printf("nearbyint (2.3) = %.1f\n", std::nearbyint(2.3)); // 2.0
printf("nearbyint (3.8) = %.1f\n", std::nearbyint(3.8)); // 4.0
printf("nearbyint (-2.3) = %.1f\n", std::nearbyint(-2.3)); // -2.0
printf("nearbyint (-3.8) = %.1f\n", std::nearbyint(-3.8)); // -4.0
}

{ // std::remainder: Returns the floating-point remainder of numer/denom(rounded to nearest)
printf("remainder of 5.3 / 2 is %f\n", std::remainder(5.3, 2)); // remainder of 5.3 / 2 is -0.7
printf("remainder of 18.5 / 4.2 is %f\n", std::remainder(18.5, 4.2)); // remainder of 18.5 / 4.2 is 1.7
}

{ // std::remquo: Returns the same as remainder, but it additionally stores the quotient
// internally used to determine its result in the object pointed by quot
double numer = 10.3;
double denom = 4.5;
int quot;
double result = std::remquo(numer, denom, ");
printf("numerator: %f\n", numer); // 10.3
printf("denominator: %f\n", denom); // 4.5
printf("remainder: %f\n", result); // 1.3
printf("quotient: %d\n", quot); // 2
}
{ // std::rint: Round to integral value
printf("rounding using ");
switch (fegetround()) {
case FE_DOWNWARD: printf("downward"); break;
case FE_TONEAREST: printf("to-nearest"); break; // to-nearest
case FE_TOWARDZERO: printf("toward-zero"); break;
case FE_UPWARD: printf("upward"); break;
default: printf("unknown");
}
printf(" rounding:\n");
printf("rint (2.3) = %.1f\n", std::rint(2.3)); // 2.0
printf("rint (3.8) = %.1f\n", std::rint(3.8)); // 4.0
printf("rint (-2.3) = %.1f\n", std::rint(-2.3)); // -2.0
printf("rint (-3.8) = %.1f\n", std::rint(-3.8)); // -4.0
}
{ // std::lrint: Rounds x to an integral value, and returns it as a value of type long int.
printf("rounding using ");
switch (fegetround()) {
case FE_DOWNWARD: printf("downward"); break;
case FE_TONEAREST: printf("to-nearest"); break; // to-nearest
case FE_TOWARDZERO: printf("toward-zero"); break;
case FE_UPWARD: printf("upward"); break;
default: printf("unknown");
}
printf(" rounding:\n");
printf("lrint (2.3) = %ld\n", std::lrint(2.3)); // 2
printf("lrint (3.8) = %ld\n", std::lrint(3.8)); // 4
printf("lrint (-2.3) = %ld\n", std::lrint(-2.3)); // -2
printf("lrint (-3.8) = %ld\n", std::lrint(-3.8)); // -4
}
{ // std::llrint: Rounds x to an integral value,returns it as a value of type long long int
printf("rounding using ");
switch (fegetround()) {
case FE_DOWNWARD: printf("downward"); break;
case FE_TONEAREST: printf("to-nearest"); break; // to-nearest
case FE_TOWARDZERO: printf("toward-zero"); break;
case FE_UPWARD: printf("upward"); break;
default: printf("unknown");
}
printf(" rounding:\n");
printf("llrint (2.3) = %lld\n", std::llrint(2.3)); // 2
printf("llrint (3.8) = %lld\n", std::llrint(3.8)); // 4
printf("llrint (-2.3) = %lld\n", std::llrint(-2.3)); // -2
printf("llrint (-3.8) = %lld\n", std::llrint(-3.8)); // -4
}
return 0;
}
int test_cmath_exp()
{
{ // std::exp: Returns the base-e exponential function of x, e^x
double param, result;
param = 1.0;
result = std::exp(param);
printf("The exponential value of %f is %f.\n", param, result); // 1.0 2.718282
}
{ // std::frexp(x, int* exp):Breaks the floating point number x into its binary significand
// (a floating point with an absolute value between 0.5(included) and 1.0(excluded)) and an integral exponent for 2
// x = significand * (2 ^ exponent)
double param, result;
int n;
param = 8.0;
result = std::frexp(param, &n);
printf("%f = %f * 2^%d\n", param, result, n); // 8.0 = 0.5 * 2^4
}
{ // std::ldexp: Returns the result of multiplying x (the significand) by 2 raised to the power of exp (the exponent)
double param, result;
int n;
param = 0.95;
n = 4;
result = std::ldexp(param, n);
printf("%f * 2^%d = %f\n", param, n, result); // 0.95 * 2^4 = 15.2
}
{ // std::exp2: Returns the base-2 exponential function of x
double param, result;
param = 8.0;
result = std::exp2(param);
printf("2 ^ %f = %f.\n", param, result); // 2^8 = 256
}
{ // std::expm1: Compute exponential minus one
double param, result;
param = 1.0;
result = std::expm1(param);
printf("expm1 (%f) = %f.\n", param, result); // expm1(1.0) = 1.718282
}
{ // std::scalbn: Scales x by FLT_RADIX raised to the power of n
double param, result;
int n;
param = 1.50;
n = 4;
result = std::scalbn(param, n);
printf("%f * %d^%d = %f\n", param, FLT_RADIX, n, result); // 1.5 * 2^4 = 24.0
}
{ // std::scalbln: Scales x by FLT_RADIX raised to the power of n
double param, result;
long n;
param = 1.50;
n = 4L;
result = std::scalbln(param, n);
printf("%f * %d^%d = %f\n", param, FLT_RADIX, n, result); // 1.5 * 2^4 = 24.0
}
return 0;
}
int test_cmath_log()
{
{ // std::log: Returns the natural logarithm of x
// The natural logarithm is the base-e logarithm: the inverse of the natural exponential function (exp)
double param, result;
param = 5.5;
result = std::log(param);
printf("log(%f) = %f\n", param, result); // ln(5.5) = 1.704748
}
{ // std::log10: Returns the common (base-10) logarithm of x
double param, result;
param = 1000.0;
result = std::log10(param);
printf("log10(%f) = %f\n", param, result); // log10(1000.0) = 3.0
}
{ // std::modf: Breaks x into an integral and a fractional part
double param, fractpart, intpart;
param = 3.14159265;
fractpart = std::modf(param, &intpart);
printf("%f = %f + %f \n", param, intpart, fractpart); // 3.14159265 = 3.0 + 0.141593
}
{ // std::ilogb: Returns the integral part of the logarithm of |x|, using FLT_RADIX as base for the logarithm.
double param;
int result;
param = 10.0;
result = std::ilogb(param);
printf("ilogb(%f) = %d\n", param, result); // ilogb(10.0) = 3
}
{ // std::log1p: Returns the natural logarithm of one plus x
double param, result;
param = 1.0;
result = std::log1p(param);
printf("log1p (%f) = %f.\n", param, result); // log1p(1.0) = 0.693147
}
{ // std::log2: Returns the binary (base-2) logarithm of x.
double param, result;
param = 1024.0;
result = std::log2(param);
printf("log2 (%f) = %f.\n", param, result); // log2(1024.0) = 10.0
}
{ // std::logb: Returns the logarithm of |x|, using FLT_RADIX as base for the logarithm
double param, result;
param = 1024.0;
result = std::logb(param);
printf("logb (%f) = %f.\n", param, result); // logb(1024.0) = 10.0
}
return 0;
}
int test_cmath_error()
{
{ // std::erf: Returns the error function value for x.
double param, result;
param = 1.0;
result = std::erf(param);
printf("erf (%f) = %f\n", param, result); // erf(1.0) = 0.842701
}
{ // std::erfc: Returns the complementary error function value for x
double param, result;
param = 1.0;
result = std::erfc(param);
printf("erfc(%f) = %f\n", param, result); // erfc(1.0) = 0.157299
}
{ // std::tgamma: Compute gamma function
double param, result;
param = 0.5;
result = std::tgamma(param);
printf("tgamma(%f) = %f\n", param, result); // tgamma(0.5) = 1.772454
}
{ // std::lgamma: Compute log-gamma function
double param, result;
param = 0.5;
result = std::lgamma(param);
printf("lgamma(%f) = %f\n", param, result); // lgamma(0.5) = 0.572365
}
return 0;
}
int test_cmath_1()
{
{ // std::copysign: Returns a value with the magnitude of x and the sign of y
printf("copysign ( 10.0,-1.0) = %f\n", std::copysign(10.0, -1.0)); // -10.0
printf("copysign (-10.0,-1.0) = %f\n", std::copysign(-10.0, -1.0)); // -10.0
printf("copysign (-10.0, 1.0) = %f\n", std::copysign(-10.0, 1.0)); // 10.0
}
{ // std::nan: Returns a quiet NaN (Not-A-Number) value of type double.
}
{ // std::nextafter: Returns the next representable value after x in the direction of y
printf("first representable value greater than zero: %e\n", std::nextafter(0.0, 1.0)); // 4.940656e-324
printf("first representable value less than zero: %e\n", std::nextafter(0.0, -1.0)); // -4.940656e-324
}
{ // std::nexttoward: Returns the next representable value after x in the direction of y
printf("first representable value greater than zero: %e\n", std::nexttoward(0.0, 1.0L)); // 4.940656e-324
printf("first representable value less than zero: %e\n", std::nexttoward(0.0, -1.0L)); // -4.940656e-324
}
return 0;
}
int test_cmath_2()
{
{ // std::fdim: The function returns x-y if x>y, and zero otherwise.
printf("fdim (2.0, 1.0) = %f\n", std::fdim(2.0, 1.0)); // 1.0
printf("fdim (1.0, 2.0) = %f\n", std::fdim(1.0, 2.0)); // 0.0
printf("fdim (-2.0, -1.0) = %f\n", std::fdim(-2.0, -1.0)); // 0.0
printf("fdim (-1.0, -2.0) = %f\n", std::fdim(-1.0, -2.0)); // 1.0
}
{ // std::fmax: Returns the larger of its arguments: either x or y
printf("fmax (100.0, 1.0) = %f\n", std::fmax(100.0, 1.0)); // 100.0
printf("fmax (-100.0, 1.0) = %f\n", std::fmax(-100.0, 1.0)); // 1.0
printf("fmax (-100.0, -1.0) = %f\n", std::fmax(-100.0, -1.0)); // -1.0
}
{ // std::fmin: Returns the smaller of its arguments: either x or y
printf("fmin (100.0, 1.0) = %f\n", std::fmin(100.0, 1.0)); // 1.0
printf("fmin (-100.0, 1.0) = %f\n", std::fmin(-100.0, 1.0)); // -100.0
printf("fmin (-100.0, -1.0) = %f\n", std::fmin(-100.0, -1.0)); // -100.0
}
return 0;
}
int test_cmath_classify()
{
{ // std::fpclassify: Returns a value of type int that matches one of the classification
// macro constants, depending on the value of x
double d = std::sqrt(-1.0); // 1.0 / 0.0;
switch (std::fpclassify(d)) {
case FP_INFINITE: printf("infinite"); break;
case FP_NAN: printf("NaN"); break; // NaN
case FP_ZERO: printf("zero"); break;
case FP_SUBNORMAL: printf("subnormal"); break;
case FP_NORMAL: printf("normal"); break;
}
if (std::signbit(d)) printf(" negative\n"); // negative
else printf(" positive or unsigned\n");
}
{ // std::isfinite: Returns whether x is a finite value
printf("isfinite(0.0) : %d\n", std::isfinite(0.0)); // 1
//printf("isfinite(1.0/0.0) : %d\n", std::isfinite(1.0 / 0.0));
//printf("isfinite(-1.0/0.0) : %d\n", std::isfinite(-1.0 / 0.0));
printf("isfinite(sqrt(-1.0)): %d\n", std::isfinite(std::sqrt(-1.0))); // 0
}
{ // std::isinf: Returns whether x is an infinity value
printf("isinf(0.0) : %d\n", std::isinf(0.0)); // 0
//printf("isinf(1.0/0.0) : %d\n", std::isinf(1.0 / 0.0));
//printf("isinf(-1.0/0.0) : %d\n", std::isinf(-1.0 / 0.0));
printf("isinf(sqrt(-1.0)): %d\n", std::isinf(std::sqrt(-1.0))); // 0
}
{ // std::isnan: Returns whether x is a NaN (Not-A-Number) value.
printf("isnan(0.0) : %d\n", std::isnan(0.0)); // 0
//printf("isnan(1.0/0.0) : %d\n", std::isnan(1.0 / 0.0));
//printf("isnan(-1.0/0.0) : %d\n", std::isnan(-1.0 / 0.0));
printf("isnan(sqrt(-1.0)): %d\n", std::isnan(std::sqrt(-1.0))); // 1
}
{ // std::isnormal: Returns whether x is a normal value
// i.e., whether it is neither infinity, NaN, zero or subnormal
printf("isnormal(1.0) : %d\n", std::isnormal(1.0)); // 1
printf("isnormal(0.0) : %d\n", std::isnormal(0.0)); // 0
//printf("isnormal(1.0/0.0): %d\n", std::isnormal(1.0 / 0.0));
}
{ // std::signbit: Returns whether the sign of x is negative
printf("signbit(0.0) : %d\n", std::signbit(0.0)); // 0
//printf("signbit(1.0/0.0) : %d\n", std::signbit(1.0 / 0.0));
//printf("signbit(-1.0/0.0) : %d\n", std::signbit(-1.0 / 0.0));
printf("signbit(sqrt(-1.0)): %d\n", std::signbit(std::sqrt(-1.0))); // 1
}
return 0;
}
int test_cmath_compare()
{
double result;
result = std::log(10.0);
{ // std::isgreater: Returns whether x is greater than y
if (std::isgreater(result, 0.0))
printf("log(10.0) is positive\n"); // log(10.0) is positive
else
printf("log(10.0) is not positive\n");
}
{ // std::isgreaterequal: Returns whether x is greater than or equal to y
if (std::isgreaterequal(result, 0.0))
printf("log(10.0) is not negative\n"); // log(10.0) is not negative
else
printf("log(10.0) is negative\n");
}
{ // std::isless: Returns whether x is less than y
if (std::isless(result, 0.0))
printf("log(10.0) is negative\n");
else
printf("log(10.0) is not negative\n"); // log(10.0) is not negative
}
{ // std::islessequal: Returns whether x is less than or equal to y
if (std::islessequal(result, 0.0))
printf("log(10.0) is not positive\n");
else
printf("log(10.0) is positive\n"); // log(10.0) is positive
}
{ // std::islessgreater: Returns whether x is less than or greater than y
if (islessgreater(result, 0.0))
printf("log(10.0) is not zero\n"); // log(10.0) is not zero
else
printf("log(10.0) is zero\n");
}
{ // std::isunordered: Returns whether x or y are unordered values
double result;
result = std::sqrt(-1.0);
if (std::isunordered(result, 0.0))
printf("sqrt(-1.0) and 0.0 cannot be ordered\n"); // sqrt(-1.0) and 0.0 cannot be ordered
else
printf("sqrt(-1.0) and 0.0 can be ordered\n");
}
return 0;
}
} // namespace cmath_

前言

这两天把《MySQL必知必会》看完了,书本讲解的内容比较实用,注重命令的教学,对原理的涉及比较少。

这篇博客再回顾、梳理一遍书本,记录一些重点的内容。

2022-07-30

概念

  • 数据库(database):保存有组织的数据的容器。
  • 数据库软件(dbms):称为DBMS(数据库管理系统)。数据库是通过DBMS创建和操纵的容器。
  • 表(table):某种特定类型数据的结构化清单(文件)。每个表都有一个名字,用来标识自己。
  • 模式(schema):关于数据库和表的布局及特性的信息。这些特性定义了数据如何存储。
  • 列(column):表中的一个字段。所有表都是由一个或多个列组成的。可以理解为一类信息放在一列,每个列都有相应的数据类型。
  • 行(row):表中的一个记录(record)。
  • 主键(primary key):一列(或一组列),其值能够唯一区分表中每个行。任何列都可以作为主键,只要它满足以下条件:
    • 任意两行都不具有相同的主键值;
    • 每个行都必须具有一个主键值(主键列不允许NULL值)。
1
2
3
4
主键的最好习惯 除MySQL强制实施的规则外,应该坚持的几个普遍认可的最好习惯为: 
不更新主键列中的值;
不重用主键列的值;
不在主键列中使用可能会更改的值。
  • SQL:(发音为字母S-Q-L或sequel)是结构化查询语言(Structured Query Language)的缩写。SQL是一种专门用来与数据库通信的语言。
  • MySQL服务器部分:负责所有数据访问和处理的一个软件。这个软件运行在称为数据库服务器的计算机上。
  • MySQL客户机部分:是与用户打交道的软件,向服务器作出请求。

基本操作

假设拥有的一个数据库名字为sample

语句用;结束,SQL语句不区分大小写,USE和use是一样的,一般关键字都用大写,这样易于阅读和调试。

SQL语句可以在一行上给出,也可以分成许多行。多数SQL开发人员认为将SQL语句分成多行更容易阅读和调试。

使用和显示

  • USE:最初连接到MySQL时,没有任何数据库打开供你使用。在执行任意数据库操作前,需要选择一个数据库。为此,可使用USE关键字(USE sample;)。必须先使用USE打开数据库,才能读取其中的数据。
  • SHOW DATABASES:SHOW DATABASES;返回可用数据库的一个列表。
  • SHOW TABLES:为了获得一个数据库内的表的列表,使用SHOW TABLES;。这个数据库是前面USE的数据库。
  • SHOW COLUMNS:SHOW COLUMNS FROM customers;要求给出一个表名(FROM someTable),它对每个字段返回一行,行中包含字段名、数据类型、是否允许NULL、键信息、默认值以及其他信息。
    • DESCRIBE customers;SHOW COLUMNS FROM customers;的一种快捷方式。
  • 其他SHOW语句(少用):
    • SHOW STATUS,用于显示广泛的服务器状态信息;
    • SHOW CREATE DATABASE和SHOW CREATE TABLE,分别用来显示创建特定数据库或表的MySQL语句;
    • SHOW GRANTS,用来显示授予用户(所有用户或特定用户)的安全权限;
    • SHOW ERRORS和SHOW WARNINGS,用来显示服务器错误或警告消息。

检索

1
2
3
-- 检索单列-- 
SELECT prod_name
FROM products;

上述语句利用SELECT语句从products表中检索一个名为prod_name的列。所需的列名在SELECT关键字之后给出,FROM关键字指出从其中检索数据的表名。

  • 未排序数据:如果没有明确排序查询结果,则返回的数据的顺序没有特殊意义。返回数据的顺序可能是数据被添加到表中的顺序,也可能不是。只要返回相同数目的行,就是正常的。
1
2
3
4
5
6
7
-- 检索多列-- 
SELECT prod_id,prod_price,prod_name
FROM products;

-- 检索所有列--
SELECT *
FROM products;

如果给定一个通配符(*),则返回表中所有列。一般,除非你确实需要表中的每个列,否则最好别使用*通配符。虽然使用通配符可能会使你自己省事,不用明确列出所需列,但检索不需要的列通常会降低检索和应用程序的性能。可以用于检索未知列

1
2
3
-- 检索不同值(若相同则只返回一次)-- 
SELECT DISTINCT vend_id
FROM products;

DISTINCT 关键字指示MySQL只返回不同的值。

不能部分使用DISTINCT DISTINCT关键字应用于所有列而不仅是前置它的列。如果给出SELECT DISTINCT vend_id, prod_price,除非指定的两个列都不同,否则所有行都将被检索出来。相当于同时比较两个列,只有一行对应的两个元素都相同才视为真的相同

1
2
3
4
5
6
7
8
9
-- 限制结果为前面n行-- 
SELECT prod_name
FROM products
LIMIT 5;

-- 限制结果为某个区间--
SELECT prod_name
FROM products
LIMIT 5,5;

LIMIT 5指示MySQL返回不多于5行。

LIMIT 5, 5指示MySQL返回从行5开始(位置从0开始计)的5行。两个数字容易搞混,因此有一种代替语法:LIMIT 4 OFFSET 3意为从行3开始取4行,就像LIMIT 3, 4一样。

1
2
3
-- 完全限定名写法,限定某个列是哪个表的(表名也可以限定为哪个数据库的)-- 
SELECT products.prod_name
FROM products;

排序

一般返回的顺序是数据最初添加到表中的顺序。

1
2
3
4
-- 排序单列-- 
SELECT prod_name
FROM products
ORDER BY prod_name;

ORDER BY指示MySQL对prod_name列以字母顺序排序,通常,ORDER BY子句中使用的列将是为显示所选择的列。但是,实际上并不一定要这样,用非检索的列排序数据是完全合法的。

1
2
3
4
-- 排序多列-- 
SELECT prod_id,prod_price,prod_name
FROM products
ORDER BY prod_price,prod_name;

在按多个列排序时,排序完全按所规定的顺序进行。换句话说,对于上述例子中的输出,仅在多个行具有相同的prod_price值时才对产品按prod_name进行排序。如果prod_price列中所有的值都是唯一的,则不会按prod_name排序。

1
2
3
4
-- 指定排序方向-- 
SELECT prod_id,prod_price,prod_name
FROM products
ORDER BY prod_price DESC,prod_name;

数据排序不限于升序排序(从A到Z)。这只是默认的排序顺序,还可以使用ORDER BY子句以降序(从Z到A)顺序排序。为了进行降序排序,必须指定DESC关键字

DESC关键字只应用到直接位于其前面的列名。在上例中,只对prod_price列指定DESC,对prod_name列不指定。因此,prod_price列以降序排序,而prod_name列(在每个价格内)仍然按标准的升序排序。

与DESC相反的关键字是ASC(ASCENDING),在升序排序时可以指定它。但实际上,ASC没有多大用处,因为升序是默认的

在字典(dictionary)排序顺序中,A被视为与a相同,这是MySQL(和大多数数据库管理系统)的默认行为。如果确实需要改变这种排序顺序,用简单的ORDER BY子句做不到。必须请求数据库管理员的帮助。

1
2
3
4
5
-- 应用:找最大值-- 
SELECT prod_price
FROM products
ORDER BY prod_price DESC
LIMIT 1;

prod_price DESC保证行是按照由最昂贵到最便宜检索的,而LIMIT 1告诉MySQL仅返回一行。

在给出ORDER BY子句时,应该保证它位于FROM子句之后。如果使用LIMIT,它必须位于ORDER BY之后。使用子句的次序不对将产生错误消息。因为SELECT-FROM这一对给出了结果,然后再调用ORDER BY 来排序,最后用LIMIT取第一行。

过滤

1
2
3
4
-- 使用WHERE子句过滤-- 
SELECT prod_name,prod_price
FROM products
WHERE prod_price = 2.50;

这条语句从products表中检索两个列,但不返回所有行,只返回prod_price值为2.50的行。

在同时使用ORDER BY和WHERE子句时,应该让ORDER BY位于WHERE之后,否则将会产生错误(因为先过滤完再排序)

  • WHERE子句操作符:
    • =:等于
    • <>:不等于
    • !=:不等于
    • <:小于
    • <=:小于等于
    • >:大于
    • >=:大于等于
    • BETWEEN:在指定的两个值之间
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- 检查单个值-- 
SELECT prod_name,prod_price
FROM products
WHERE prod_price = 'fuses';

-- 范围检查--
SELECT prod_name,prod_price
FROM products
WHERE prod_price <= 10;

SELECT prod_name,prod_price
FROM products
WHERE prod_price BETWEEN 5 AND 10; -- 两个值必须用AND分隔开--

-- 不匹配检查--
SELECT prod_name,vend_id
FROM products
WHERE vend_id <> 1003;

-- 空值检查--
SELECT prod_name,prod_price
FROM products
WHERE prod_price IS NULL; -- 注意这里用了IS关键字--
  • NULL:无值(no value),它与字段包含0、空字符串或仅仅包含空格不同。
  • NULL与不匹配:在通过过滤选择出不具有特定值的行时,你可能希望返回具有NULL值的行。但是不行。因为未知具有特殊的含义,数据库不知道它们是否匹配,所以在匹配过滤或不匹配过滤时不返回它们
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-- AND操作符-- 
SELECT prod_name,prod_price,prod_id
FROM products
WHERE vend_id = 1003 AND prod_price <= 10;

-- OR操作符--
SELECT prod_name,prod_price,prod_id
FROM products
WHERE vend_id = 1003 OR vend_id = 1005;

-- 使用优先级组合--
SELECT prod_name,prod_price,prod_id
FROM products
WHERE vend_id = 1002 OR vend_id = 1003 AND prod_price <= 10;

-- 使用圆括号组合--
SELECT prod_name,prod_price,prod_id
FROM products
WHERE (vend_id = 1002 OR vend_id) = 1003 AND prod_price <= 10;
  • AND:用在WHERE子句中的关键字,用来指示检索满足所有给定条件的行。
  • OR:WHERE子句中使用的关键字,用来表示检索匹配任一给定条件的行。
  • 优先级:SQL(像多数语言一样)在处理OR操作符前,优先处理AND操作符。此问题的解决方法是使用圆括号明确地分组相应的操作符。任何时候使用具有AND和OR操作符的WHERE子句,都应该使用圆括号明确地分组操作符,它能消除歧义。
1
2
3
4
-- IN操作符-- 
SELECT prod_name,prod_price,prod_id
FROM products
WHERE vend_id IN (1002,1003);

IN操作符完成与OR相同的功能,IN操作符用来指定条件范围,范围中的每个条件都可以进行匹配。IN取合法值的由逗号分隔的清单,全都括在圆括号中。优点:

  • 在使用长的合法选项清单时,IN操作符的语法更清楚且更直观。
  • 在使用IN时,计算的次序更容易管理(因为使用的操作符更少)。
  • IN操作符一般比OR操作符清单执行更快。
  • IN的最大优点是可以包含其他SELECT语句,使得能够更动态地建立WHERE子句。
1
2
3
4
-- NOT操作符-- 
SELECT prod_name,prod_price,prod_id
FROM products
WHERE vend_id NOT IN (1002,1003);

这里的NOT否定跟在它之后的条件,在更复杂的子句中,NOT是非常有用的。在与IN操作符联合使用时,NOT使找出与条件列表不匹配的行非常简单。

MySQL支持使用NOT对IN、BETWEEN和EXISTS子句取反,这与多数其他DBMS允许使用NOT对各种条件取反有很大的差别。

通配符过滤

  • 通配符(wildcard):用来匹配值的一部分的特殊字符。

  • 搜索模式(search pattern):由字面值、通配符或两者组合构成的搜索条件。

  • 百分号**%**代表搜索模式中给定位置的0个、1个或多个字符。

  • 下划线**_**总是匹配一个字符,不能多也不能少。

为在搜索子句中使用通配符,必须使用LIKE操作符。

1
2
3
SELECT prod_id,prod_name
FROM products
WHERE prod_name LIKE 'jet%';

此例子使用了搜索模式**’jet%’。在执行这条子句时,将检索任意以jet起头的词。%告诉MySQL接受jet之后的任意字符**,不管它有多少字符。

1
2
3
SELECT prod_id,prod_name
FROM products
WHERE prod_name LIKE '%anvil%';

搜索模式**’%anvil%’表示匹配任何位置包含文本anvil**的值,而不论它之前或之后出现什么字符。

1
2
3
SELECT prod_id,prod_name
FROM products
WHERE prod_name LIKE 's%e';

这个例子找出以s起头以e结尾的所有产品。

1
2
3
4
-- 使用_通配符-- 
SELECT prod_id,prod_name
FROM products
WHERE prod_name LIKE '_ ton anvil';
  • 尾空格可能会干扰通配符匹配。例如,在保存词anvil 时,如果它后面有一个或多个空格,则子句WHERE prod_name LIKE '%anvil'将不会匹配它们,因为在最后的l后有多余的字符。解决这个问题的一个简单的办法是在搜索模式最后附加一个%。一个更好的办法是使用函数去掉首尾空格。
  • 注意NULL:虽然似乎%通配符可以匹配任何东西,但有一个例外,即NULL。即使是WHERE prod_name LIKE ‘%’也不能匹配用值NULL作为产品名的行。

使用通配符是有代价的:通配符搜索的处理一般要比前面讨论的其他搜索所花时间更长。技巧:

  • 不要过度使用通配符。如果其他操作符能达到相同的目的,应该使用其他操作符。
  • 在确实需要使用通配符时,除非绝对有必要,否则不要把它们用在搜索模式的开始处。把通配符置于搜索模式的开始处,搜索起来是最慢的
  • 仔细注意通配符的位置。如果放错地方,可能不会返回想要的数据。

正则表达式

1
2
3
4
-- REGEXP关键字-- 
SELECT prod_id,prod_name
FROM products
WHERE prod_name REGEXP '1000';

这个语句检索列prod_name包含文本1000的所有行(注意,用LIKE要使用通配符才能检查’’包含’’),关键字LIKE被REGEXP代替,它告诉MySQL:REGEXP后所跟的东西作为正则表达式(与文字正文1000匹配的一个正则表达式)处理。

1
2
3
4
-- REGEXP关键字-- 
SELECT prod_id,prod_name
FROM products
WHERE prod_name REGEXP '.000';

这里使用了正则表达式.000。**.是正则表达式语言中一个特殊的字符。它表示匹配任意一个字符**,因此,1000和2000都匹配且返回。

  • MySQL中的正则表达式匹配(自版本3.23.4后)不区分大小写(即,大写和小写都匹配)。为区分大小写,可使用BINARY关键字,如WHERE prod_name REGEXP BINARY 'JetPack .000'
1
2
3
4
-- OR匹配-- 
SELECT prod_id,prod_name
FROM products
WHERE prod_name REGEXP '1000|2000';

语句中使用了正则表达式1000|2000。|为正则表达式的OR操作符。它表示匹配其中之一,因此1000和2000都匹配并返回。可以有两个以上的OR条件。

1
2
3
4
-- 匹配几个字符之一-- 
SELECT prod_id,prod_name
FROM products
WHERE prod_name REGEXP '[123] Ton';

这里,使用了正则表达式[123] Ton。[123]定义一组字符,它的意思是匹配1或2或3,因此,1 ton和2 ton都匹配且返回(没有3 ton)。

[]是另一种形式的OR语句。事实上,正则表达式[123]Ton为[1|2|3]Ton的缩写,也可以使用后者。但是,需要用[]来定义OR语句查找什么。如果直接用'1|2|3 Ton',MySQL则假定你的意思是’1’或’2’或’3 ton’。

  • 字符集合也可以被否定,即,它们将匹配除指定字符外的任何东西。为否定一个字符集,在集合的开始处放置一个^即可。因此,尽管[123]匹配字符1、2或3,但**[^123]**却匹配除这些字符外的任何东西。

  • 如果要匹配数字,则为集合[0123456789],一种简化的写法为[0-9](用’-‘定义一个范围)。范围不限于完整的集合,[1-3]和[6-9]也是合法的范围。此外,范围不一定只是数值的,[a-z]匹配任意字母字符。

  • 如果要匹配’.’,不能直接REGEXP ‘.’,因为这会匹配任意一个字符。需要用两个反斜杠转义,即在特殊字符前加\\。如\\.

    • 为了匹配反斜杠(\)字符本身,需要使用\\\
    • MySQL要求两个反斜杠(MySQL自己解释一个,正则表达式库解释另一个)。
    • \\也用来引用元字符,如\\f表示换页。

存在找出经常使用的数字、所有字母字符或所有数字字母字符等的匹配。为更方便工作,可以使用预定义的字符集,称为字符类(character class):

  • [:alnum:] :任意字母和数字(同[a-zA-Z0-9])
  • [:alpha:] :任意字符(同[a-zA-Z])
  • [:blank:] :空格和制表(同[\t])
  • [:cntrl:] :ASCII控制字符(ASCII 0到31和127)
  • [:digit:] :任意数字(同[0-9])
  • [:graph:] :与[:print:]相同,但不包括空格
  • [:lower:] :任意小写字母(同[a-z])
  • [:print:] :任意可打印字符
  • [:punct:] :既不在[:alnum:]又不在[:cntrl:]中的任意字符
  • [:space:] :包括空格在内的任意空白字符(同[\f\n\r\t\v])
  • [:upper:] :任意大写字母(同[A-Z])
  • [:xdigit:] :任意十六进制数字(同[a-fA-F0-9])

字符汇总

  • .:匹配任意一个字符
  • |:表示OR(条件或)
  • []:另一种形式的OR语句,来定义OR语句查找什么
  • ^:否定
  • -:定义范围
  • \\:转义
  • [: :]:前面所示的一些字符类
  • *:0个或多个匹配,置于某字符后
  • +:1个或多个匹配(等于{1,}),置于某字符后
  • ?:0个或1个匹配(等于{0,1}),置于某字符后
  • {n}:指定数目的匹配,置于某字符后
  • {n,}:不少于指定数目的匹配,置于某字符后
  • {n,m}:匹配数目的范围(m不超过255),置于某字符后
  • ^:文本的开始(双重用途),放在串的开头
  • $:文本的结尾,放在串的结尾
  • [[:<:]]:词的开始
  • [[:>:]]:词的结尾

^的双重用途:^有两种用法。在集合中(用[和]定义),用它来否定该集合,否则,用来指串的开始处,如^[0-9\\.](后面有例子)。

1
2
3
4
-- 使用?-- 
SELECT prod_id,prod_name
FROM products
WHERE prod_name REGEXP '\\([0-9] sticks?\\)';

正则表达式\\([0-9] sticks?\\)需要解说一下。\\(匹配(,[0-9]匹配任意数字,sticks?匹配stick和sticks(s后的?使s可选,因为?匹配它前面的任何字符的0次或1次出现),\\)匹配)。没有?,匹配stick和sticks会非常困难。

1
2
3
4
-- 匹配连在一起的4位数字-- 
SELECT prod_id,prod_name
FROM products
WHERE prod_name REGEXP '[:digit:]{4}';

[:digit:]匹配任意数字,因而它为数字的一个集合。{4}确切地要求它前面的字符(任意数字)出现4次。

如果想找出以一个数(包括以小数点开始的数)开始的所有产品,怎么办?简单搜索[0-9\\.](或[[:digit:]\\.])不行,因为它将在文本内任意位置查找匹配(正则是查找”包含“)。解决办法是使用^定位符。

1
2
3
SELECT prod_id,prod_name
FROM products
WHERE prod_name REGEXP '^[0-9\\.]';

^匹配串的开始。因此,^[0-9\\.]只在.或任意数字为串中第一个字符时才匹配它们。

  • 使REGEXP起类似LIKE的作用 :前面说过,LIKE和REGEXP的不同在于,LIKE匹配整个串而REGEXP匹配子串。利用定位符,通过用^开始每个表达式,用$结束每个表达式,可以使REGEXP的作用与LIKE一样。

进阶操作

创建计算字段

  • 字段(field) :基本上与列(column)的意思相同,经常互换使用,不过数据库列一般称为列,而术语字段通常用在计算字段的连接上。
1
2
3
4
-- 拼接字段-- 
SELECT Concat(vend_name,'(',vend_country,')')
FROM vendors
ORDER BY vend_name;

Concat()拼接串,即把多个串连接起来形成一个较长的串。Concat()需要一个或多个指定的串,各个串之间用逗号分隔。

多数DBMS使用+或||来实现拼接,MySQL则使用Concat()函数来实现。当把SQL语句转换成MySQL语句时一定要把这个区别铭记在心。

1
2
3
4
-- 删除右侧多余空格-- 
SELECT Concat(RTrim(vend_name),'(',RTrim(vend_country),')')
FROM vendors
ORDER BY vend_name;

RTrim()函数去掉值右边的所有空格。通过使用RTrim(),各个列都进行了整理。MySQL除了支持RTrim()(正如刚才所见,它去掉串右边的空格),还支持LTrim()(去掉串左边的空格)以及Trim()(去掉串左右两边的空格)。

1
2
3
4
-- 使用别名-- 
SELECT Concat(RTrim(vend_name),'(',RTrim(vend_country),')') AS vend_title
FROM vendors
ORDER BY vend_name;

计算字段之后跟了文本AS vend_title。它指示SQL创建一个包含指定计算的名为vend_title的计算字段。别名有时也称为导出列(derived column)。

1
2
3
4
-- 算术计算-- 
SELECT prod_id,quantity,item_price,quantity*item_price AS expanded_price
FROM orderitems
WHERE order_num = 20005;

输出中显示的expanded_price列为一个计算字段,此计算为quantity*item_price。客户机应用现在可以使用这个新计算列,就像使用其他列一样。

  • 测试计算:SELECT提供了测试和试验函数与计算的一个很好的办法。虽然SELECT通常用来从表中检索数据,但可以省略FROM子句以便简单地访问和处理表达式。例如,SELECT 3*2;将返回6,SELECT Trim(‘abc’);将返回abc,而SELECT Now()利用Now()函数返回当前日期和时间。通过这些例子,可以明白如何根据需要使用SELECT进行试验。

使用函数

文本处理函数

  • RTrim(str):去掉串尾的空格来整理数据。

  • Right(str):返回串右边的字符。

  • Upper(str):将文本转换为大写并返回。

  • Lower(str):将文本转换为小写并返回。

  • Length(str):返回串的长度。

  • Locate(substr,str):substr待查找的子串,str待查找的串。如果 substr 不在 str 中返回 0。

  • Locate(substr,str,pos):返回子串 substr 在字符串 str 中的第 pos 位置后第一次出现的位置。如果 substr 不在 str 中返回 0。

  • Position(substr IN str):返回substr在str中第一次出现的位置。

  • SubString(str,pos):返回从第pos位置出现的子串的字符。

  • substring(str, pos, len):substring(str, pos, len)。作用:返回从pos位置开始长度为len的子串的字符。

  • Soundex(str):返回串的SOUNDEX值。

    • SOUNDEX是一个将任何文本串转换为描述其语音表示的字母数字模式的算法。SOUNDEX考虑了类似的发音字符和音节,使得能对串进行发音比较而不是字母比较。

    • ```mysql
      – 匹配Y.Lee与Y.Lie–
      SELECT cust_name,cust_contact
      FROM customers
      WHERE Soundex(cust_contact) = Soundex(‘Y.Lie’);

      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



      ### 日期和时间处理函数

      * AddDate() :增加一个日期(天、周等)
      * AddTime() :增加一个时间(时、分等)
      * CurDate() :返回当前日期
      * CurTime() :返回当前时间
      * Date() :返回日期时间的日期部分
      * DateDiff() :计算两个日期之差
      * Date_Add() :高度灵活的日期运算函数
      * Date_Format() :返回一个格式化的日期或时间串
      * Day() :返回一个日期的天数部分
      * DayOfWeek() :对于一个日期,返回对应的星期几
      * Hour() :返回一个时间的小时部分
      * Minute() :返回一个时间的分钟部分
      * Month() :返回一个日期的月份部分
      * Now() :返回当前日期和时间
      * Second() :返回一个时间的秒部分
      * Time() :返回一个日期时间的时间部分
      * Year() :返回一个日期的年份部分

      需要注意的是MySQL使用的日期格式。无论你什么时候指定一个日期,不管是插入或更新表值还是用WHERE子句进行过滤,日期必须为格式yyyy-mm-dd。因此,2005年9月1日,给出为2005-09-01。虽然其他的日期格式可能也行,但这是首选的日期格式,因为它排除了多义性。

      因为一个记录里面的日期格式可能不一样,比如有可能这个日期还包括**当天时间**,这样就需要指示MySQL**仅将给出的日期与列中的日期部分进行比较**,而不是将给出的日期与整个列值进行比较。为此,必须使用Date()函数。Date(order_date)指示MySQL仅提取列的日期部分。

      ```mysql
      SELECT cust_id,order_num
      FROM orders
      WHERE Date(order_date) = '2005-09-01';

如果你想要的仅是日期,则使用Date()是一个良好的习惯,即使你知道相应的列只包含日期也是如此。这样,如果由于某种原因表中以后有日期和时间值,你的SQL代码也不用改变。当然,也存在一个Time()函数,在你只想要时间时应该使用它。

1
2
3
4
5
6
7
8
9
-- 检索某月1:需要知道一个月有多少天-- 
SELECT cust_id,order_num
FROM orders
WHERE Date(order_date) BETWEEN '2005-09-01' AND '2005-09-30';

-- 检索某月2--
SELECT cust_id,order_num
FROM orders
WHERE Year(order_date)=2005 ANd Month(order_date)=9;

数值处理函数

  • Abs() :返回一个数的绝对值
  • Cos() :返回一个角度的余弦
  • Exp() :返回一个数的指数值
  • Mod() :返回除操作的余数
  • Pi() :返回圆周率
  • Rand() :返回一个随机数
  • Sin() :返回一个角度的正弦
  • Sqrt() :返回一个数的平方根
  • Tan() :返回一个角度的正切

聚焦函数(汇总数据)

利用标准的算术操作符,所有聚集函数都可用来执行多个列上的计算(如两个列乘法,取平均、最大、求和)。这些函数是高效设计的,它们返回结果一般比在自己的客户机应用程序中计算要快得多。

  • AVG() :返回某列的平均值,只能用于特定列,忽略NULL的行。
  • COUNT() :返回某列的行数,指定列忽略NULL,count(*)包含NULL。
  • MAX() :返回某列的最大值,忽略NULL,可用于数值、日期、文本。
  • MIN() :返回某列的最小值,忽略NULL,可用于数值、日期、文本。
  • SUM() :返回某列值之和,忽略NULL。
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
-- 整体AVG()-- 
SELECT AVG(prod_price) AS avg_price
FROM products;
-- 部分AVG()--
SELECT AVG(prod_price) AS avg_price
FROM products
WHERE vend_id = 1003;

-- 所有COUNT()--
SELECT COUNT(*) AS num_cust
FROM customers;
-- 指定COUNT()--
SELECT COUNT(cust_email) AS num_cust
FROM customers;

-- MAX()--
SELECT MAX(prod_price) AS max_price
FROM products;

-- MIN()--
SELECT MIN(prod_price) AS min_price
FROM products;

-- 多列算术SUM()--
SELECT SUM(item_price*quantity) AS total_price
FROM orderitems
WHERE order_num = 20005;

聚焦不同值: 对所有的行执行计算,指定ALL参数或不给参数(因为ALL是默认行为);只包含不同的值,指定DISTINCT参数。

1
2
3
4
-- 所有聚焦函数都可用DISTINCT-- 
SELECT AVG(DISTINCT prod_price) AS avg_price
FROM products
WHERE vend_id = 1003;
  • 如果指定列名,则DISTINCT只能用于COUNT()。DISTINCT不能用于COUNT(*),因此不允许使用COUNT(DISTINCT),否则会产生错误。类似地,DISTINCT必须使用列名,不能用于计算或表达式。
  • 虽然DISTINCT从技术上可用于MIN()和MAX(),但这样做实际上没有价值。一个列中的最小值和最大值不管是否包含不同值都是相同的。
1
2
3
4
5
6
-- 组合-- 
SELECT COUNT(*) AS num_items,
MIN(prod_price) AS price_min,
MAX(prod_price) AS price_max,
AVG(prod_price) AS price_avg,
FROM products;
  • 指定别名以包含某个聚集函数的结果时,不应该使用表中实际的列名。虽然这样做并非不合法,但使用唯一的名字会使你的SQL更易于理解和使用(以及将来容易排除故障)。

分组

1
2
3
SELECT vend_id,COUNT(*) AS num_prods
FROM products
GROUP BY vend_id;

上面的SELECT语句指定了两个列,vend_id包含产品供应商的ID,num_prods为计算字段(用COUNT(*)函数建立)。GROUP BY子句指示MySQL按vend_id排序并分组数据,然后对每个组进行聚焦。这导致对每个vend_id而不是整个表计算num_prods一次。

使用ROLLUP,可以把NULL也分组,否则不会。

1
2
3
SELECT vend_id,COUNT(*) AS num_prods
FROM products
GROUP BY vend_id WITH ROLLUP;

在具体使用GROUP BY子句前,需要知道一些重要的规定:

  • GROUP BY子句可以包含任意数目的列。这使得能对分组进行嵌套,为数据分组提供更细致的控制。
  • 如果在GROUP BY子句中嵌套了分组,数据将在最后规定的分组上进行汇总。换句话说,在建立分组时,指定的所有列都一起计算(所以不能从个别的列取回数据)。
  • GROUP BY子句中列出的每个列都必须是检索列或有效的表达式(但不能是聚集函数)。如果在SELECT中使用表达式,则必须在GROUP BY子句中指定相同的表达式。不能使用别名。
  • 除聚集计算语句外,SELECT语句中的每个列都必须在GROUP BY子句中给出。
  • 如果分组列中具有NULL值,则NULL将作为一个分组返回。如果列中有多行NULL值,它们将分为一组。
  • GROUP BY子句必须出现在WHERE子句之后,ORDER BY子句之前。
1
2
3
4
5
-- 过滤分组-- 
SELECT cust_id,COUNT(*) AS orders
FROM orders
GROUP BY cust_id
HAVING COUNT(*)>=2;

WHERE过滤指定的是行而不是分组。事实上,WHERE没有分组的概念。HAVING非常类似于WHERE。事实上,目前为止所学过的所有类型的WHERE子句都可以用HAVING来替代。唯一的差别是WHERE过滤行,而HAVING过滤分组。

这条SELECT语句的前3行类似于上面的语句。最后一行增加了HAVING子句,它过滤COUNT(*) >=2(两个以上的订单)的那些分组。

这里有另一种理解方法,WHERE在数据分组前进行过滤,HAVING在数据分组后进行过滤。这是一个重要的区别,WHERE排除的行不包括在分组中。这可能会改变计算值,从而影响HAVING子句中基于这些值过滤掉的分组。

1
2
3
4
5
6
-- 都使用-- 
SELECT cust_id,COUNT(*) AS orders
FROM orders
WHERE prod_price>=10
GROUP BY cust_id
HAVING COUNT(*)>=2;
1
2
3
4
5
6
-- 排序-- 
SELECT order_num,SUM(quantity*item_price) AS ordertotal
FROM orderitems
GROUP BY order_num
HAVING SUM(quantity*item_price)>=50
ORDER BY ordertotal;

一般在使用GROUP BY子句时,应该也给出ORDER BY子句。这是保证数据正确排序的唯一方法。千万不要仅依赖GROUP BY排序数据。

SELECT子句及其顺序:

SELECT -- > FROM -- > WHERE -- > GROUP BY -- > HAVING -- > ORDER BY -- > LIMIT

子查询

  • 查询(query):任何SQL语句都是查询。但此术语一般指SELECT语句。
  • 递归的子查询将下层的查询结果返回给上层利用,一般在WHERE子句里通过IN利用。
  • 迭代的子查询将上层第一次迭代的结果拿来给自身的循环利用。
1
2
3
4
5
6
7
8
-- 嵌套子查询,相当于递归-- 
SELECT cust_name,cust_contact
FROM customers
WHERE cust_id IN (SELECT cust_id
FROM orders
WHERE order_num IN (SELECT order_num
FROM orderitems
WHERE prod_id = 'TNT2'));
  • 列必须匹配:在WHERE子句中使用子查询(如这里所示),应该保证SELECT语句具有与WHERE子句中相同数目的列。通常,子查询将返回单个列并且与单个列匹配,但如果需要也可以使用多个列。
  • 虽然子查询一般与IN操作符结合使用,但也可以用于测试等于(=)、不等于(<>)等。
1
2
3
4
5
6
-- 在SELECT上子查询,迭代的,相当于多层循环-- 
SELECT cust_name,cust_state,(SELECT COUNT(*)
FROM orders
WHERE orders.cust_id=customers.cust_id) AS orders
FROM customers
ORDER BY cust_name;

每次外部执行一次,得到一个id,用此id去orders里面遍历一次查询来次数。

这种类型的子查询称为相关子查询。任何时候只要列名可能有多义性,就必须使用这种语法(表名和列名由一个句点分隔)。

联结

基础联结

关系数据可以有效地存储和方便地处理。因此,关系数据库的可伸缩性远比非关系数据库要好。

联结的创建非常简单,规定要联结的所有表以及它们如何关联即可。

1
2
3
4
5
-- 创建联结-- 
SELECT vend_name,prod_name,prod_price
FROM vendors,products -- 多个表--
WHERE vendors.vend_id = products.vend_id -- 联结--
ORDER BY vend_name,prod_name;

上面例子的步骤是:先检索第一项vend_name的一项(对应一个id),然后发现接下来是另一个表的内容(实际上在表检索的过程有遍历的先后次序,是嵌套的,具体是看FROM哪个表先,两个表是n*n的复杂度),则在这个表中检索一遍,匹配id。WHERE子句指示MySQL匹配vendors表中的vend_id和products表中的vend_id。如果没有WHERE,则对vend_name的一项都对应另一个表的所有项。

  • 笛卡儿积(cartesian product):由没有联结条件的表关系返回的结果为笛卡儿积。检索出的行的数目将是第一个表中的行数乘以第二个表中的行数。也即第一个表的一项都匹配了第二个表的所有项(这里可以看出实质上是嵌套遍历的)。
1
2
3
4
5
-- 另一种写法,内部联结-- 
SELECT vend_name,prod_name,prod_price
FROM vendors INNER JOIN products -- 多个表,指明了嵌套关系--
ON vendors.vend_id = products.vend_id -- 联结条件--
ORDER BY vend_name,prod_name;

两个表之间的关系是FROM子句的组成部分,以INNER JOIN指定。在使用这种语法时,联结条件用特定的ON子句而不是WHERE子句给出。

ANSI SQL规范首选INNER JOIN语法。此外,尽管使用WHERE子句定义联结的确比较简单,但是使用明确的联结语法能够确保不会忘记联结条件,有时候这样做也能影响性能。

1
2
3
4
5
6
7
8
9
10
11
12
INNER JOIN嵌套语法

INNER JOIN 连接两个数据表的用法:
SELECT * FROM 表1 INNER JOIN 表2 ON 表1.字段号=表2.字段号

INNER JOIN 连接三个数据表的用法:
SELECT * FROM (表1 INNER JOIN 表2 ON 表1.字段号=表2.字段号) INNER JOIN 表3 ON 表1.字段号=表3.字段号

INNER JOIN 连接四个数据表的用法:
SELECT * FROM ((表1 INNER JOIN 表2 ON 表1.字段号=表2.字段号) INNER JOIN 表3 ON 表1.字段号=表3.字段号) INNER JOIN 表4 ON 表1.字段号=表4.字段号

......
1
2
3
4
5
6
7
8
9
10
-- 联结多个表-- 
SELECT prod_name,vend_name,prod_price,quantity
FROM orderitems,products,vendors
WHERE products.vend_id = vendors.vend_id AND orderitems.prod_id = products.prod_id AND order_num = 20005;

-- INNER JOIN写法--
SELECT prod_name,vend_name,prod_price,quantity
FROM (orderitems INNER JOIN products ON orderitems.prod_id = products.prod_id) INNER JOIN vendors
ON orderitems.prod_id = vendors.vend_id
WHERE orderitems.order_num = 20005;
1
2
3
4
5
6
7
8
9
10
11
12
13
-前面的子查询-- 
SELECT cust_name,cust_contact
FROM customers
WHERE cust_id IN (SELECT cust_id
FROM orders
WHERE order_num IN (SELECT order_num
FROM orderitems
WHERE prod_id = 'TNT2'));

-- 改用联结--
SELECT cust_name,cust_contact
FROM customers,orders,orderitems
WHERE customers.cust_id = orders_cust_id AND orderitems.order_num = orders.order_num AND prod_id = 'TNT2';

子查询的意思是从orderitems表查到order_num,然后去orders表根据这个order_num查找cust_id,然后再去customers找name和contact。

而使用联结,则先在customers表查第一项,得到id,然后去orders表查到这个id的项获得num,然后去orderitems表根据num获得prod_id,检查是不是’TNT2’,是的话算一个结果,然后继续下一项,就相当于循环迭代

实际上这两个方法的核心都是:给定的索引与目标记录不在一个表中。那么可以递归不断换个表,也可以更清晰地用外键联结这几个表。

高级联结

使用别名,能够在单条SELECT语句中多次使用相同的表,并缩短语句长度。

1
2
3
4
-- 使用别名-- 
SELECT cust_name,cust_contact
FROM customers AS c,orders AS o,orderitems AS oi
WHERE c.cust_id = o.cust_id AND oi.order_num = o.order_num AND prod_id = 'TNT2';

假如你发现某物品(其ID为DTNTR)存在问题,因此想知道生产该物品的供应商生产的其他物品是否也存在这些问题。此查询要求首先找到生产ID为DTNTR的物品的供应商,然后找出这个供应商生产的其他物品。

1
2
3
4
5
6
7
8
9
10
11
12
-- 子查询-- 
SELECT prod_id,prod_name
FROM products
WHERE vend_id = (SELECT vend_id
FROM products
WHERE prod_id = 'DTNTR');

-- 自联结--
SELECT p1.prod_id,p1.prod_name
FROM products AS p1,products AS p2
WHERE p1.vend_id = p2.vend_id AND p2.prod_id = 'DTNTR';
-- 从p2中找到一条记录,从p1中找到多条记录。虽然是相同的表,但每个实例执行的条件不一样。从循环迭代的角度看,条件有传递的感觉--

此查询中需要的两个表实际上是相同的表,因此products表在FROM子句中出现了两次。虽然这是完全合法的,但对products的引用具有二义性,因为MySQL不知道你引用的是products表中的哪个实例。为解决此问题,使用了表别名。products的第一次出现为别名p1,第二次出现为别名p2。现在可以将这些别名用作表名。

  • 自然联结是这样一种联结,只能选择那些唯一的列。这一般是通过对表使用通配符(SELECT *),对所有其他表的列使用明确的子集来完成的。事实上,这里迄今为止建立的每个内部联结都是自然联结,很可能我们永远都不会用到不是自然联结的内部联结。
  • 外部联结联结包含了那些在相关表中没有关联行的行,使用OUTER JOIN关键字。在使用OUTER JOIN语法时,必须使用RIGHTLEFT关键字指定包括其所有行的表(RIGHT指出的是OUTER JOIN右边的表,而LEFT指出的是OUTER JOIN左边的表)。
1
2
3
4
-- 外部联结-- 
SELECT customers.cust_id,orders.order_num
FROM customers LEFT OUTER JOIN orders
ON customers.cust_id = orders.cust_id;

上面这条语句会把所有的cust_id都打印,因为使用的LEFT关键字指定了customers的表,而右边order_num是不是NULL都会打印出来。

1
2
3
4
5
6
7
8
-- 聚焦函数与联结-- 
SELECT customers.cust_name,customers.cust_id,COUNT(orders.order_num) AS num_ord
FROM customers INNER JOIN orders
ON customers.cust_id = orders.cust_id
GROUP BY customers.cust_id;

-- 对客户表,联结订单表。--
-- 对客户分组,然后对于每个客户通过联结条件匹配订单表的对应客户,接着计算匹配的订单数量--

组合查询

多数SQL查询都只包含从一个或多个表中返回数据的单条SELECT语句。MySQL也允许执行多个查询(多条SELECT语句),并将结果作为单个查询结果集返回。这些组合查询通常称为并(union)或复合查询(compound query)

有两种基本情况,其中需要使用组合查询:

  • 在单个查询中从不同的表返回类似结构的数据;
  • 对单个表执行多个查询,按单个查询返回数据。

可用UNION操作符来组合数条SQL查询。利用UNION,可给出多条SELECT语句,将它们的结果组合成单个结果集。

1
2
3
4
5
6
7
8
-- 组合查询-- 
SELECT vend_id,prod_id,prod_price
FROM products
WHERE prod_price<=5 -- 注意,这条语句没有分号--
UNION
SELECT vend_id,prod_id,prod_price
FROM products
WHERE vend_id IN (1001,1002);

这条语句由前面的两条SELECT语句组成,语句中用UNION关键字分隔。UNION指示MySQL执行两条SELECT语句,并把输出组合成单个查询结果集。

并是非常容易使用的。但在进行并时有几条规则需要注意:

  • UNION必须由两条或两条以上的SELECT语句组成,语句之间用关键字UNION分隔(因此,如果组合4条SELECT语句,将要使用3个UNION关键字)。
  • UNION中的每个查询必须包含相同的列、表达式或聚集函数(不过各个列不需要以相同的次序列出)。
  • 列数据类型必须兼容:类型不必完全相同,但必须是DBMS可以隐含地转换的类型(例如,不同的数值类型或不同的日期类型)。

UNION从查询结果集中自动去除了重复的行,这是UNION的默认行为,但是如果需要,可以改变它。事实上,如果想返回所有匹配行,可使用UNION ALL而不是UNION。

SELECT语句的输出用ORDER BY子句排序。在用UNION组合查询时,只能使用一条ORDER BY子句,它必须出现在最后一条SELECT语句之后。对于结果集,不存在用一种方式排序一部分,而又用另一种方式排序另一部分的情况,因此不允许使用多条ORDER BY子句。

全文本搜索

1
2
并非所有引擎都支持全文本搜索 
MySQL支持几种基本的数据库引擎。并非所有的引擎都支持本书所描述的全文本搜索。两个最常使用的引擎为MyISAM和InnoDB,前者支持全文本搜索,而后者不支持。这就是为什么虽然本书中创建的多数样例表使用 InnoDB ,而有一个样例表(productnotes表)却使用MyISAM的原因。如果你的应用中需要全文本搜索功能,应该记住这一点。

FULLTEXT

一般在创建表时启用全文本搜索。CREATE TABLE语句接受FULLTEXT子句,它给出被索引列的一个逗号分隔的列表。

1
2
3
4
5
6
7
8
9
CREATE TABLE productnotes
(
note_id int NOT NULL AUTO_INCREMENT,
prod_id char(10) NOT NULL,
note_date datetime NOT NULL,
note_text text NULL,
PRIMARY KEY(note_id),
FULLTEXT(note_text)
)ENGINE=MyISAM;

这些列中有一个名为note_text的列,为了进行全文本搜索,MySQL根据子句FULLTEXT(note_text)的指示对它进行索引。这里的FULLTEXT索引单个列,如果需要也可以指定多个列。不要在导入数据时使用FULLTEXT ,要花很多时间。

在索引之后,使用两个函数Match()和Against()执行全文本搜索,其中Match()指定被搜索的列,Against()指定要使用的搜索表达式。

1
2
3
SELECT note_text
FROM productnotes
WHERE Match(note_text) Against('rabbit');

此SELECT语句检索单个列note_text。由于WHERE子句,一个全文本搜索被执行。Match(note_text)指示MySQL针对指定的列进行搜索,Against(‘rabbit’)指定词rabbit作为搜索文本。由于有两行包含词rabbit,这两个行被返回。传递给 Match() 的值必须与FULLTEXT()定义中的相同。如果指定多个列,则必须列出它们(而且次序正确)。

全文本搜索的一个重要部分就是对结果排序。具有较高等级的行先返回(因为这些行很可能是你真正想要的行)。

  • 文本中词靠前的行的等级值比词靠后的行的等级值高。
  • 如果指定多个搜索项,则包含多数匹配词的那些行将具有比包含较少词(或仅有一个匹配)的那些行高的等级值。

除非使用BINARY方式(本章中没有介绍),否则全文本搜索不区分大小写。

1
2
3
4
-- 查询扩展-- 
SELECT note_text
FROM productnotes
WHERE Match(note_text) Against('anvils' WITH QUERY EXPANSION);

利用查询扩展,能找出可能相关的结果,即使它们并不精确包含所查找的词。比如某一行x确实包含词anvils,另一行不包含,但这行包含了行x中的几个词,那么也有可能被检索出来。

布尔模式

以布尔方式,可以提供关于如下内容的细节:

  • 要匹配的词;
  • 要排斥的词(如果某行包含这个词,则不返回该行,即使它包含其他指定的词也是如此);
  • 排列提示(指定某些词比其他词更重要,更重要的词等级更高);
  • 表达式分组;
  • 另外一些内容。

即使没有FULLTEXT索引也可以使用:布尔方式不同于迄今为止使用的全文本搜索语法的地方在于,即使没有定义FULLTEXT索引,也可以使用它。但这是一种非常缓慢的操作(其性能将随着数据量的增加而降低)。

1
2
3
4
-- 布尔模式-- 
SELECT note_text
FROM productnotes
WHERE Match(note_text) Against('heavy' IN BOOLEAN MODE);

此全文本搜索检索包含词heavy的所有行(有两行)。其中使用了关键字IN BOOLEAN MODE,但实际上没有指定布尔操作符,因此,其结果与没有指定布尔方式的结果相同。

为了匹配包含heavy但不包含任意以rope开始的词的行,可使用以下查询:

1
2
3
4
-- 布尔模式-- 
SELECT note_text
FROM productnotes
WHERE Match(note_text) Against('heavy -rope*' IN BOOLEAN MODE);

这一次仍然匹配词heavy,但-rope*明确地指示MySQL排除包含rope*(任何以rope开始的词,包括ropes)的行。

布尔操作符:

  • + :包含,词必须存在
  • - :排除,词必须不出现
  • > :包含,而且增加等级值
  • < :包含,且减少等级值
  • () :把词组成子表达式(允许这些子表达式作为一个组被包含、排除、排列等)
  • ~ :取消一个词的排序值
  • * :词尾的通配符
  • “” :定义一个短语(与单个词的列表不一样,它匹配整个短语以便包含或排除这个短语)

在布尔方式中,不按等级值降序排序返回的行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 匹配包含rabbit、bait至少一个词的行,这种叫单词列表-- 
SELECT note_text
FROM productnotes
WHERE Match(note_text) Against('rabbit bait' IN BOOLEAN MODE);

-- 匹配包含词rabbit、bait的行--
SELECT note_text
FROM productnotes
WHERE Match(note_text) Against('+rabbit +bait' IN BOOLEAN MODE);

-- 匹配短语rabbit bait--
SELECT note_text
FROM productnotes
WHERE Match(note_text) Against('"rabbit bait"' IN BOOLEAN MODE);

说明

  • 在索引全文本数据时,短词被忽略且从索引中排除。短词定义为那些具有3个或3个以下字符的词(如果需要,这个数目可以更改)。
  • MySQL带有一个内建的非用词(stopword)列表,这些词在索引全文本数据时总是被忽略。如果需要,可以覆盖这个列表(请参阅MySQL文档以了解如何完成此工作)。
  • 许多词出现的频率很高,搜索它们没有用处(返回太多的结果)。因此,MySQL规定了一条50%规则,如果一个词出现在50%以上的行中,则将它作为一个非用词忽略。50%规则不用于IN BOOLEAN MODE。
  • 如果表中的行数少于3行,则全文本搜索不返回结果(因为每个词或者不出现,或者至少出现在50%的行中)。
  • 忽略词中的单引号。例如,don’t索引为dont。
  • 不具有词分隔符(包括日语和汉语)的语言不能恰当地返回全文本搜索结果。
  • 如前所述,仅在MyISAM数据库引擎中支持全文本搜索。

数据

插入行

INSERT语句一般不会产生输出。有两种方式

不管使用哪种INSERT语法,都必须给出VALUES的正确数目。如果不提供列名,则必须给每个表列提供一个值。如果提供列名,则必须对每个列出的列给出一个值。如果不这样,将产生一条错误消息,相应的行插入不成功。

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
-- 简单的方式-- 
INSERT INTO customers
VALUES('Pep E. LaPew',
'100 Main Street',
'Los Angeles',
'CA',
'90046',
'USA',
NULL,
NULL);

-- 更安全、建议的插入方式--
INSERT INTO customers(cust_name,
cust_address,
cust_city,
cust_state,
cust_zip,
cust_country,
cust_contact,
cust_email)
VALUES('Pep E. LaPew',
'100 Main Street',
'Los Angeles',
'CA',
'90046',
'USA',
NULL,
NULL);

在表名后的括号里明确地给出了列名。在插入行时,MySQL将用VALUES列表中的相应值填入列表中的对应项。VALUES中的第一个值对应于第一个指定的列名。第二个值对应于第二个列名,如此等等。

存储到每个表列中的数据在VALUES子句中给出,对每个列必须提供一个值。如果某个列没有值(如上面的cust_contact和cust_email列),应该使用NULL值(假定表允许对该列指定空值)。

cust_id可以不填也可以为NULL。这是因为每次插入一个新行时,该列由MySQL自动增量。如果某一列要省略,必须满足:该列定义为允许NULL,或者在表定义时给出了默认值。

INSERT可能会降低等待处理的SELECT语句的性能,如果数据检索是重要的,那么可以在INSERT和INTO之间添加关键字LOW_PRIORITY,指示MySQL降低INSERT语句的优先级,如下所示:INSERT LOW_PRIORITY INTO

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
-- 插入多个行-- 
INSERT INTO customers(cust_name,
cust_address,
cust_city,
cust_state,
cust_zip,
cust_country,
cust_contact,
cust_email)
VALUES('Pep E. LaPew',
'100 Main Street',
'Los Angeles',
'CA',
'90046',
'USA',
NULL,
NULL),
('Lep E. PaPew',
'100 Main Street',
'Los Angeles',
'CA',
'90046',
'USA',
NULL,
NULL);

只要每条INSERT语句中的列名(和次序)相同,可以一次插入多个行。其中单条INSERT语句有多组值,每组值用一对圆括号括起来,用逗号分隔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
-- 插入检索出来的行-- 
INSERT INTO customers(cust_id,
cust_name,
cust_address,
cust_city,
cust_state,
cust_zip,
cust_country,
cust_contact,
cust_email)
SELECT cust_id,
cust_name,
cust_address,
cust_city,
cust_state,
cust_zip,
cust_country,
cust_contact,
cust_email
FROM custnew;

这个例子使用INSERT SELECT从custnew中将所有数据导入customers。SELECT语句从custnew检索出要插入的值,而不是列出它们。SELECT中列出的每个列对应于customers表名后所跟的列表中的每个列。这条语句将插入多少行有赖于custnew表中有多少行。如果这个表为空,则没有行被插入(也不产生错误,因为操作仍然是合法的)。如果这个表确实含有数据,则所有数据将被插入到customers。

为简单起见,这个例子在INSERT和SELECT语句中使用了相同的列名。但是,不一定要求列名匹配。事实上,MySQL甚至不关心SELECT返回的列名,它使用的是列的位置。

更新行

UPDATE语句由3部分组成,分别是:

  • 要更新的表;
  • 列名和它们的新值;
  • 确定要更新行的过滤条件。

实际上,UPDATE是选定某列更新,不过用WHERE来选中某些行。

1
2
3
UPDATE customers
SET cust_email = 'elmer@fudd.com'
WHERE cust_id = 10005;

UPDATE语句总是以要更新的表的名字开始。在此例子中,要更新的表的名字为customers。SET命令用来将新值赋给被更新的列。如这里所示,SET子句设置cust_email列为指定的值:SET cust_email = 'elmer@fudd.com'

UPDATE语句以WHERE子句结束,它告诉MySQL更新哪一行。没有WHERE子句,MySQL将会用这个电子邮件地址更新customers表中所有行。

1
2
3
4
5
-- 更新多个列的行-- 
UPDATE customers
SET cust_email = 'elmer@fudd.com',
cust_name = 'The Fudds'
WHERE cust_id = 10005;

在更新多个列时,只需要使用单个SET命令,每个“列=值”对之间用逗号分隔(最后一列之后不用逗号)。

IGNORE关键字:如果用UPDATE语句更新多行,并且在更新这些行中的一行或多行时出一个现错误,则整个UPDATE操作被取消(错误发生前更新的所有行被恢复到它们原来的值)。为即使是发生错误,也继续进行更新,可使用IGNORE关键字,如下所示: UPDATE IGNORE customers…

1
2
3
-- 删除某列,设置为NULL-- 
UPDATE customers
SET cust_email = NULL;

没有WHERE子句则更新所有行。

删除行

DELETE直接删除一整行,不能选择列。

1
2
DELETE FROM customers
WHERE cust_id = 10006;

这条语句很容易理解。DELETE FROM要求指定从中删除数据的表名。WHERE子句过滤要删除的行。在这个例子中,只删除客户10006。如果省略WHERE子句,它将删除表中每个客户。

DELETE语句从表中删除行,甚至是删除表中所有行。但是,DELETE不删除表本身。

如果想从表中删除所有行,不要使用DELETE。可使用TRUNCATE TABLE语句,它完成相同的工作,但速度更快(TRUNCATE实际是删除原来的表并重新创建一个表,而不是逐行删除表中的数据)。

MySQL没有撤销(undo)按钮。应该非常小心地使用UPDATE和DELETE,否则你会发现自己更新或删除了错误的数据。

创建表

在创建新表时,指定的表名必须不存在。

1
2
3
4
5
6
7
8
9
CREATE TABLE customers
(
cust_id int NOT NULL AUTO_INCREMENT, -- 自动增量--
cust_name char(10) NOT NULL,
cust_address char(50) NOT NULL, -- 不允许NULL--
cust_city char(50) NULL, -- 允许NULL--
...
PRIMARY KEY(cust_id)
)ENGINE=MyISAM;

表名紧跟在CREATE TABLE关键字后面。实际的表定义(所有列)括在圆括号之中。各列之间用逗号分隔。每列的定义以列名(它在表中必须是唯一的)开始,后跟列的数据类型。表的主键可以在创建表时用PRIMARY KEY关键字指定。这里,列cust_id指定作为主键列。忽略ENGINE时,整条语句由右圆括号后的分号结束。

NULL为默认设置,如果不指定NOT NULL,则认为指定的是NULL。不允许NULL值的列不接受该列没有值的行

1
2
3
4
5
6
7
8
9
10
-- 多个主键列-- 
CREATE TABLE customers
(
cust_id int NOT NULL AUTO_INCREMENT, -- 自动增量--
cust_name char(10) NOT NULL,
cust_address char(50) NOT NULL, -- 不允许NULL--
cust_city char(50) NULL, -- 允许NULL--
...
PRIMARY KEY(cust_id,cust_name)
)ENGINE=MyISAM;

为创建由多个列组成的主键,应该以逗号分隔的列表给出各列名。如果主键使用单个列,则它的值必须唯一。如果使用多个列,则这些列的组合值必须唯一。主键中只能使用不允许NULL值的列。允许NULL值的列不能作为唯一标识。

  • 每个表只允许一个AUTO_INCREMENT列,而且它必须被索引(如,通过使它成为主键)。要指定某个自动增量的值,可以简单地在INSERT语句中指定一个值,只要它是唯一的(至今尚未使用过)即可,该值将被用来替代自动生成的值。后续的增量将开始使用该手工插入的值。
  • 让MySQL生成(通过自动增量)主键的一个缺点是你不知道这些值都是谁。如何在使用AUTO_INCREMENT列时获得这个值呢?可使用last_insert_id()函数获得这个值:SELECT last_insert_id()。此语句返回最后一个AUTO_INCREMENT值,然后可以将它用于后续的MySQL语句。
1
2
3
4
5
6
7
8
9
10
-- 指定默认值-- 
CREATE TABLE customers
(
cust_id int NOT NULL AUTO_INCREMENT, -- 自动增量--
cust_name char(10) NOT NULL DEFAULT 'xiaoming', -- 有默认值--
cust_address char(50) NOT NULL, -- 不允许NULL--
cust_city char(50) NULL, -- 允许NULL--
...
PRIMARY KEY(cust_id,cust_name)
)ENGINE=MyISAM;
  • MySQL不允许使用函数作为默认值,它只支持常量。
  • 许多数据库开发人员使用默认值而不是NULL列,特别是对用于计算或数据分组的列更是如此。

如果省略ENGINE=语句,则使用漠人引擎(很可能是MyISAM),不同表可以使用不同的引擎类型,但外键不能跨引擎。引擎类型:

  • InnoDB是一个可靠的事务处理引擎(参见第26章),它不支持全文本搜索;
  • MEMORY在功能等同于MyISAM,但由于数据存储在内存(不是磁盘)中,速度很快(特别适合于临时表);
  • MyISAM是一个性能极高的引擎,它支持全文本搜索,但不支持事务处理。

注:创建表为避免名称重复,可以用CREATE TABLE IF NOT EXISTS

更新表

为了使用ALTER TABLE更改表结构,必须给出下面的信息:

  • 在ALTER TABLE之后给出要更改的表名(该表必须存在,否则将出错);
  • 所做更改的列表。
1
2
3
4
5
6
7
8
9
10
11
12
-- 更新表,添加一个列-- 
ALTER TABLE vendors
ADD vend_phone CHAR(20); -- 默认NULL--

-- 更新表,删除一个列--
ALTER TABLE vendors
DROP COLUMN vend_phone;

-- 常见用途:定义外键--
ALTER TABLE orderitems
ADD CONSTRAINT fk_orderitems_orders
FOREIGN KEY (order_num) REFERENCES orders(order_num);

使用ALTER TABLE要极为小心,应该在进行改动前做一个完整的备份(模式和数据的备份)。数据库表的更改不能撤销,如果增加了不需要的列,可能不能删除它们。类似地,如果删除了不应该删除的列,可能会丢失该列中的所有数据。

删除和重命名表

1
2
3
4
5
-- 删除表-- 
DROP TABLE customers;

-- 重命名表--
RENAME TABLE customers TO customers2;

技巧

使用视图

视图就是把类似SELECT语句的结果保存(但不是真实的保存了数据),在以后需要这些数据时直接用这个视图不用重新SELECT。因为视图不包含数据,所以每次使用视图时,都必须处理查询执行时所需的任一个检索。如果你用多个联结和过滤创建了复杂的视图或者嵌套了视图,可能会发现性能下降得很厉害。

  • 视图用CREATE VIEW语句来创建。
  • 使用SHOW CREATE VIEW viewname;来查看创建视图的语句。
  • 用DROP删除视图,其语法为DROP VIEW viewname;
  • 更新视图时,可以先用DROP再用CREATE,也可以直接用CREATE OR REPLACE VIEW。如果要更新的视图不存在,则第2条更新语句会创建一个视图;如果要更新的视图存在,则第2条更新语句会替换原有视图。
1
2
3
4
5
6
7
8
9
10
11
-- 创建视图-- 
CREATE VIEW productcustomers AS
SELECT cust_name,cust_contact,prod_id
FROM customers,orders,orderitems
WHERE customers.cust_id = orders.cust_id
AND orderitems.order_num = orders.order_num;

-- 使用视图--
SELECT cust_name,cust_contact
FROM productcustomers
WHERE prod_id = 'TNT2';

视图还可以用来格式化检索的结果,加入经常需要某个格式的结果。不必在每次需要时执行一些操作。

1
2
3
4
5
-- 具有某种格式的视图-- 
CREATE VIEW vendorlocations AS
SELECT Concat(RTrim(vend_name),'(',RTrim(vend_country),')') AS vend_title
FROM vendors
ORDER BY vend_name;

也可以用来过滤某些数据

1
2
3
4
5
-- 具有过滤功能的视图-- 
CREATE VIEW customeremaillist AS
SELECT cust_id,cust_name,cust_email
FROM customers
WHERE cust_email IS NOT NULL;

视图与计算字段

1
2
3
4
-- 包含计算字段的视图-- 
CREATE VIEM orderitemsexpanded AS
SELECT prod_id,quantity,item_price,quantity*item_price AS expanded_price
FROM orderitems;

通常,视图是可更新的(即,可以对它们使用INSERT、UPDATE和DELETE)。更新一个视图将更新其基表(可以回忆一下,视图本身没有数据)。如果你对视图增加或删除行,实际上是对其基表增加或删除行

但是,并非所有视图都是可更新的。基本上可以说,如果MySQL不能正确地确定被更新的基数据,则不允许更新(包括插入和删除)。这实际上意味着,如果视图定义中有以下操作,则不能进行视图的更新:

  • 分组(使用GROUP BY和HAVING);
  • 联结;
  • 子查询;
  • 并;
  • 聚集函数(Min()、Count()、Sum()等);
  • DISTINCT;
  • 导出(计算)列。

一般,应该将视图用于检索(SELECT语句)而不用于更新(INSERT、UPDATE和DELETE)。

使用存储过程

存储过程简单来说,就是为以后的使用而保存的一条或多条MySQL语句的集合。可将其视为批文件,虽然它们的作用不仅限于批处理。

使用存储过程的一些理由,换句话说,使用存储过程有3个主要的好处,即简单、安全、高性能:

  • 通过把处理封装在容易使用的单元中,简化复杂的操作。
  • 由于不要求反复建立一系列处理步骤,这保证了数据的完整性。如果所有开发人员和应用程序都使用同一(试验和测试)存储过程,则所使用的代码都是相同的。由于不要求反复建立一系列处理步骤,这保证了数据的完整性。如果所有开发人员和应用程序都使用同一(试验和测试)存储过程,则所使用的代码都是相同的。
  • 简化对变动的管理。如果表名、列名或业务逻辑(或别的内容)有变化,只需要更改存储过程的代码。使用它的人员甚至不需要知道这些变化。这一点的延伸就是安全性。通过存储过程限制对基础数据的访问减少了数据讹误(无意识的或别的原因所导致的数据讹误)的机会。
  • 提高性能。因为使用存储过程比使用单独的SQL语句要快。
  • 存在一些只能用在单个请求中的MySQL元素和特性,存储过程可以使用它们来编写功能更强更灵活的代码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- 创建存储过程,无参数-- 
CREATE PROCEDURE productpricing()
BEGIN
SELECT Avg(prod_price) AS priceaverage
FROM products; -- 注意这里有个分号,直接这样在命令行写会导致语句到这里就停止,有错误--
END;

-- 使用--
CALL productpricing();

-- 删除,如果不存在将产生错误--
DROP PROCEDURE productpricing; -- 注意没有()--

-- 删除,如果不存在也不会产生错误--
DROP PROCEDURE productpricing IF EXISTS;

对于mysql命令行程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
默认的MySQL语句分隔符为;(正如你已经在迄今为止所使用的MySQL语句中所看到的那样)。mysql命令行实用程序也使用;作为语句分隔符。如果命令行实用程序要解释存储过程自身内的;字符,则它们最终不会成为存储过程的成分,这会使存储过程中的SQL出现句法错误。解决办法是临时更改命令行实用程序的语句分隔符,如下所示(delimiter是分隔符的意思):

DELIMITER //

CREATE PROCEDURE productpricing()
BEGIN
SELECT Avg(prod_price) AS priceaverage
FROM products;
END//

DELIMITER ;

其中,DELIMITER //告诉命令行实用程序使用//作为新的语句结束分隔符,可以看到标志存储过程结束的END定义为END //而不是END;。这样,存储过程体内的;仍然保持不动,并且正确地传递给数据库引擎。最后,为恢复为原来的语句分隔符。
除\符号外,任何字符都可以用作语句分隔符。
1
2
3
4
5
6
7
8
9
10
11
12
13
-- 有参数的存储过程-- 
CREATE PROCEDURE productpricing(
OUT pl DECIMAL(8,2),
OUT ph DECIMAL(8,2),
OUT pa DECIMAL(8,2))
BEGIN
SELECT MIN(prod_price) INTO pl FROM products;
SELECT MAX(prod_price) INTO ph FROM products;
SELECT Avg(prod_price) INTO pa FROM products;
END;

-- 使用--
CALL productpricing(@pricelow,@pricehigh,@priceaverage); -- 调用,但不返回结果,而是保存在变量里--

这个存储过程执行三条语句,接受3个参数,pl存储产品最低价格,ph存储产品最高价格,pa存储产品平均价格。每个参数必须具有指定的类型,这里使用十进制值。关键字OUT指出相应的参数用来从存储过程传出一个值(返回给调用者),关键字IN用来给存储过程传入一个值。通过指定INTO关键字,把检索的值保存到相应的变量。

记录集不是允许的类型,因此不能通过一个参数返回多个行和列。这就是前面的例子为什么要使用3个参数(和3 条SELECT语句)的原因。

  • 变量名:所有MySQL变量都必须以@开始。当传入这样一个@开始的变量时,会自动创建并保存。

  • 然后可以检索这个变量:

    1
    SELECT @priceaverage;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- 有传入参数 --  
CREATE PROCEDURE ordertotal(
IN onumber INT,
OUT ototal DECIMAL(8,2)
)
BEGIN
SELECT SUM(item_price*quantity)
FROM orderitems
WHERE order_num = onumber
INTO ototal; -- 检索结果放入变量--
END;

-- 使用 --
CALL ordertotal(20005,@total);
SELECT @total;

onumber定义为IN,因为订单号被传入存储过程。ototal定义为OUT,因为要从存储过程返回合计。

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
-- 一个复杂的例子--
CREATE PROCEDURE ordertotal(
IN onumber INT,
IN taxable BOOLEAN,
OUT ototal DECIMAL(8,2)
)COMMENT 'obtain order total, optionally adding tax'
BEGIN
DECLARE total DECIMAL(8,2); -- 声明局部变量
DECLARE taxrate INT DEFAULT 6; -- 声明税率,默认为6%

SELECT SUM(item_price*quantity)
FROM orderitems
WHERE order_num = onumber
INTO total;

IF taxtable THEN
SELECT total+(total/100*taxrate) INTO total; -- 使用SELECT把计算式的结果检索,然后放入变量
END IF;

SELECT total INTO ototal; -- 实际上,这个total局部变量有点没必要
END;

--使用--
CALL ordertotal(20005,0,@total);
SELECT @total;

添加了另外一个参数taxable,它是一个布尔值(如果要增加税则为真,否则为假)。在存储过程体中,用DECLARE语句定义了两个局部变量。DECLARE要求指定变量名和数据类型,它也支持可选的默认值(这个例子中的taxrate的默认被设置为6%)。IF语句检查taxable是否为真,如果为真,则用另一SELECT语句增加营业税到局部变量total。

  • COMMENT关键字:本例子中的存储过程在CREATE PROCEDURE语句中包含了一个COMMENT值。它不是必需的,但如果给出,将在SHOW PROCEDURE STATUS的结果中显示。
  • IF语句:这个例子给出了MySQL的IF语句的基本用法。IF语句还支持ELSEIF和ELSE子句(前者还使用THEN子句,后者不使用)。在以后章节中我们将会看到IF的其他用法(以及其他流控制语句)。
1
2
3
4
5
6
7
8
-- 检查CREATE创建存储过程时用到的语句--
SHOW CREATE PROCEDURE ordertotal;

-- 获取何时、由谁创建等详细信息的存储过程列表--
SHOW PROCEDURE STATUS;

-- 限制状态结果,使用LIKE过滤--
SHOW PROCEDURE STATUS LIKE 'ordertotal';

使用游标

游标主要用于交互式应用,其中用户需要滚动屏幕上的数据,并对数据进行浏览或做出更改。不像多数DBMS,MySQL游标只能用于存储过程(和函数)

游标用DECLARE语句创建

1
2
3
4
5
6
7
-- 创建游标--
CREATE PROCEDURE processorders() -- 存储过程
BEGIN
DECLARE ordernumbers CURSOR -- 创建游标
FOR
SELECT order_num FROM orders;
END;

游标用OPEN CURSOR语句来打开,由CLOSE CURSOR语句关闭,使用声明过的游标不需要再次声明,用OPEN语句打开它就可以了。如果你不明确关闭游标,MySQL将会在到达END语句时自动关闭它。

1
2
3
4
5
-- 打开游标--
OPEN ordernumbers;

-- 关闭游标--
CLOSE ordernumbers;

在一个游标被打开后,可以使用FETCH语句分别访问它的每一行(将自动从第一行开始)。FETCH指定检索什么数据(所需的列),检索出来的数据存储在什么地方。它还向前移动游标中的内部行指针,使下一条FETCH语句检索下一行(不重复读取同一行)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
CREATE PROCEDURE processorders()
BEGIN
-- 定义局部变量
DECLARE o INT;

-- 定义游标
DECLARE ordernumbers CURSOR
FOR
SELECT order_num FROM orders;

-- 打开游标
OPEN ordernumbers;

-- 获取一行数据
FETCH ordernumbers INTO o;

-- 关闭游标
CLOSE ordernumbers;
END;
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
-- 使用循环--
CREATE PROCEDURE processorders()
BEGIN
-- 定义局部变量
DECLARE o INT;
DECLARE done BOOLEAN DEFAULT 0;

-- 定义游标
DECLARE ordernumbers CURSOR
FOR
SELECT order_num FROM orders;

-- 定义处理器
DECLARE CONTINUE HANDLER FOR SQLSTATE '02000' SET done=1;

-- 打开游标
OPEN ordernumbers;

-- 开始循环
REPEAT
FETCH ordernumbers INTO o; -- 获取一行数据
-- 结束循环
UNTIL done END REPEAT;

-- 关闭游标
CLOSE ordernumbers;
END;

与前一个例子不一样的是,这个例子中的FETCH是在REPEAT内,因此它反复执行直到done为真(由UNTIL done END REPEAT;规定)为使它起作用,用一个DEFAULT 0(假,不结束)定义变量done。

DECLARE CONTINUE HANDLER FOR SQLSTATE '02000' SET done=1;这条语句定义了一个CONTINUE HANDLER,它是在条件出现时被执行的代码。它指出当SQLSTATE ‘02000’出现时,SET done=1。SQLSTATE ‘02000’是一个未找到条件,当REPEAT由于没有更多的行供循环而不能继续时,出现这个条件。

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
-- 复杂的例子--
CREATE PROCEDURE processorders()
BEGIN
-- 定义局部变量
DECLARE o INT;
DECLARE t DECIMAL(8,2);
DECLARE done BOOLEAN DEFAULT 0;

-- 定义游标
DECLARE ordernumbers CURSOR
FOR
SELECT order_num FROM orders;

-- 定义处理器
DECLARE CONTINUE HANDLER FOR SQLSTATE '02000' SET done=1;

-- 创建一个表来存储结果
CREATE TABLE IF NOT EXISTS ordertotals
(order_num INT,total DECIMAL(8,2));

-- 打开游标
OPEN ordernumbers;

-- 开始循环
REPEAT

FETCH ordernumbers INTO o; -- 获取一行数据
CALL ordertotal(o,1,t); -- 这是存储过程章节中写的一个存储过程,因为变量定义过了,不用加@
INSERT INTO ordertotals(order_num,total)
VALUES(o,t); -- 插入表中

-- 结束循环
UNTIL done END REPEAT;

-- 关闭游标
CLOSE ordernumbers;
END;

触发器

触发器是MySQL响应以下任意语句而自动执行的一条MySQL语句(或位于BEGIN和END语句之间的一组语句):

  • DELETE;
  • INSERT;
  • UPDATE。

其他MySQL语句不支持触发器。

只有表才支持触发器,视图不支持(临时表也不支持)。每个表每个事件每次只允许一个触发器。因此,每个表最多支持6个触发器(每条INSERT、UPDATE和DELETE的之前和之后)。单一触发器不能与多个事件或多个表关联。

在创建触发器时,需要给出4条信息:

  • 唯一的触发器名(尽可能保持每个数据库的触发器名唯一);
  • 触发器关联的表;
  • 触发器应该响应的活动(DELETE、INSERT或UPDATE);
  • 触发器何时执行(处理之前或之后)。
1
2
3
4
MYSQL5以后,不允许触发器返回任何结果,因此使用into @变量名,将结果赋值到变量中,用select调用即可。修改为

CREATE TRIGGER newproduct AFTER INSERT ON products
FOR EACH ROW SELECT 'Product added' INTO @asn;

CREATE TRIGGER用来创建名为newproduct的新触发器。触发器可在一个操作发生之前或之后执行,这里给出了AFTER INSERT,所以此触发器将在INSERT语句成功执行后执行。这个触发器还指定FOR EACH ROW,因此代码对每个插入行执行。

如果BEFORE触发器失败,则MySQL将不执行请求的操作。此外,如果BEFORE触发器或语句本身失败,MySQL将不执行AFTER触发器(如果有的话)。

1
2
-- 删除触发器--
DROP TRIGGER newproduct;

触发器不能更新或覆盖。为了修改一个触发器,必须先删除它,然后再重新创建。

在MySQL中用old和new表示执行前和执行后的数据。

INSERT触发器

INSERT触发器在INSERT语句执行之前或之后执行。需要知道以下几点:

  • 在INSERT触发器代码内,可引用一个名为NEW的虚拟表,访问被插入的行;
  • 在BEFORE INSERT触发器中,NEW中的值也可以被更新(允许更改被插入的值);
  • 对于AUTO_INCREMENT列,NEW在INSERT执行之前包含0,在INSERT执行之后包含新的自动生成值。
1
2
CREATE TRIGGER neworder AFTER INSERT ON orders
FOR EACH ROW SELECT NEW.order_num INTO @asn;

此代码创建一个名为neworder的触发器,它按照AFTER INSERT ON orders执行。在插入一个新订单到orders表时,MySQL生成一个新订单号并保存到order_num中。触发器从NEW. order_num取得这个值并返回它。此触发器必须按照AFTER INSERT执行,因为在BEFORE INSERT语句执行之前,新order_num还没有生成。对于orders的每次插入使用这个触发器将总是返回新的订单号。

通常,将BEFORE用于数据验证和净化(目的是保证插入表中的数据确实是需要的数据)。本提示也适用于UPDATE触发器。

DELETE 触发器

DELETE触发器在DELETE语句执行之前或之后执行。需要知道以下两点:

  • 在DELETE触发器代码内,你可以引用一个名为OLD的虚拟表,访问被删除的行;
  • OLD中的值全都是只读的,不能更新。
1
2
3
4
5
6
CREATE TRIGGER deleteorder BEFORE DELETE ON orders
FOR EACH ROW
BEGIN
INSERT INTO archive_orders(order_num,order_date,cust_id)
VALUES(OLD.order_num,OLD.order_date,OLD.cust_id);
END;

在任意订单被删除前将执行此触发器。它使用一条INSERT语句将OLD中的值(要被删除的订单)保存到一个名为archive_ orders的存档表中。

使用BEGIN END块的好处是触发器能容纳多条SQL语句(在BEGIN END块中一条挨着一条)。

UPDATE触发器

UPDATE触发器在UPDATE语句执行之前或之后执行。需要知道以下几点:

  • 在UPDATE触发器代码中,你可以引用一个名为OLD的虚拟表访问以前(UPDATE语句前)的值,引用一个名为NEW的虚拟表访问新更新的值;(这是因为INSERT不存在OLD(插入了就是新的),DELETE不存在NEW(删除的肯定的旧的))
  • 在BEFORE UPDATE触发器中,NEW中的值可能也被更新(允许更改将要用于UPDATE语句中的值);
  • OLD中的值全都是只读的,不能更新。
1
2
3
CREATE TRIGGER updatevendor BEFORE UPDATE ON vendors
FOR EACH ROW
SET NEW.vend_state = Upper(NEW.vend_state);

这个例子可以看出一些NEW的本质,实际上NEW中的值就是将要插入或更新的内容,当有触发器时,先写到NEW,再写到表中。因此这里在写到表前(BEFORE),先对NEW中的内容进行改变(SET)。

一些重点

  • 创建触发器可能需要特殊的安全访问权限,但是,触发器的执行是自动的。如果INSERT、UPDATE或DELETE语句能够执行,则相关的触发器也能执行。
  • 应该用触发器来保证数据的一致性(大小写、格式等)。在触发器中执行这种类型的处理的优点是它总是进行这种处理,而且是透明地进行,与客户机应用无关。
  • 触发器的一种非常有意义的使用是创建审计跟踪。使用触发器,把更改(如果需要,甚至还有之前和之后的状态)记录到另一个表非常容易。
  • MySQL触发器中不支持CALL语句。这表示不能从触发器内调用存储过程。所需的存储过程代码需要复制到触发器内。

事务处理

事务处理(transaction processing)可以用来维护数据库的完整性,它保证成批的MySQL操作要么完全执行,要么完全不执行。

利用事务处理,可以保证一组操作不会中途停止,它们或者作为整体执行,或者完全不执行(除非明确指示)。如果没有错误发生,整组语句提交给(写到)数据库表。如果发生错误,则进行回退(撤销)以恢复数据库到某个已知且安全的状态。

下面是关于事务处理需要知道的几个术语:

  • 事务(transaction)指一组SQL语句;
  • 回退(rollback)指撤销指定SQL语句的过程;
  • 提交(commit)指将未存储的SQL语句结果写入数据库表;
  • 保留点(savepoint)指事务处理中设置的临时占位符(placeholder),你可以对它发布回退(与回退整个事务处理不同)。

事务处理用来管理INSERTUPDATEDELETE语句。你不能回退SELECT语句。(这样做也没有什么意义。)你不能回退CREATE或DROP操作。事务处理块中可以使用这两条语句,但如果你执行回退,它们不会被撤销。

1
2
3
4
5
6
7
8
9
10
11
12
13
-- 开始事务--
START TRANSACTION;

-- 撤销--
ROLLBACK;

-- 例子--
SELECT * FROM ordertotals;
START TRANSACTION; -- 开启事务
DELETE FROM ordertotals; -- 删除
SELECT * FROM ordertotals; -- 确实删除了
ROLLBACK; -- 回退(撤销)
SELECT * FROM ordertotals; -- 内容又存在了

这个例子从显示ordertotals表的内容开始。首先执行一条SELECT以显示该表不为空。然后开始一个事务处理,用一条DELETE语句删除ordertotals中的所有行。另一条SELECT语句验证ordertotals确实为空。这时用一条ROLLBACK语句回退START TRANSACTION之后的所有语句,最后一条SELECT语句显示该表不为空。

显然,ROLLBACK只能在一个事务处理内使用(在执行一条STARTTRANSACTION命令之后)。

一般的MySQL语句都是直接针对数据库表执行和编写的。这就是所谓的隐含提交(implicit commit),即提交(写或保存)操作是自动进行的。在事务处理块中,提交不会隐含地进行。为进行明确的提交,使用COMMIT语句。

1
2
3
4
START TRANSACTION
DELETE FROM orderitems WHERE order_num = 20010;
DELETE FROM orders WHERE order_num = 20010;
COMMIT; -- 提交

最后的COMMIT语句仅在不出错时写出更改。如果第一条DELETE起作用,但第二条失败,则DELETE不会提交(实际上,它是被自动撤销的)。

当COMMIT或ROLLBACK语句执行后,事务会自动关闭(将来的更改会隐含提交)。

为了支持回退部分事务处理,必须能在事务处理块中合适的位置放置占位符。这样,如果需要回退,可以回退到某个占位符。这些占位符称为保留点。为了创建占位符,可使用SAVEPOINT语句。每个保留点都取标识它的唯一名字,以便在回退时,MySQL知道要回退到何处。

1
2
3
4
5
-- 创建保留点--
SAVEPOINT delete1;

-- 回退保留点--
ROLLBACK TO delete1;

保留点在事务处理完成(执行一条ROLLBACK或COMMIT)后自动释放。自MySQL 5以来,也可以用RELEASE SAVEPOINT明确地释放保留点。

如果要取消自动提交,可以用:SET autocommit = 0;

其他

全球化和本地化

数据库表被用来存储和检索数据。不同的语言和字符集需要以不同的方式存储和检索。因此,MySQL需要适应不同的字符集(不同的字母和字符),适应不同的排序和检索数据的方法。在讨论多种语言和字符集时,将会遇到以下重要术语:

  • 字符集为字母和符号的集合;
  • 编码为某个字符集成员的内部表示;
  • 校对为规定字符如何比较的指令。

校对为什么重要:排序英文正文很容易,对吗?或许不。考虑词APE、apex和Apple。它们处于正确的排序顺序吗?这有赖于你是否想区分大小写。使用区分大小写的校对顺序,这些词有一种排序方式,使用不区分大小写的校对顺序有另外一种排序方式。这不仅影响排序(如用ORDER BY排序数据),还影响搜索(例如,寻找apple的WHERE子句是否能找到APPLE)。在使用诸如法文à或德文ö这样的字符时,情况更复杂,在使用不基于拉丁文的字符集(日文、希伯来文、俄文等)时,情况更为复杂。

1
2
3
4
5
6
7
-- 查看所支持的字符集完整列表--
SHOW CHARACTER SET;
-- 这条语句显示所有可用的字符集以及每个字符集的描述和默认校对。

-- 查看所支持的校对的完整列表--
SHOW COLLATION;
-- 此语句显示所有可用的校对,以及它们适用的字符集。许多校对出现两次,一次区分大小写(由_cs表示),一次不区分大小写(由_ci表示)。

通常系统管理在安装时定义一个默认的字符集和校对。此外,也可以在创建数据库时,指定默认的字符集和校对。为了确定所用的字符集和校对,可以使用以下语句:

1
2
3
-- 百分号作为通配符,匹配0,1,多个任意字符--
SHOW VARIABLES LIKE 'character%';
SHOW VARIABLES LIKE 'collation%';

实际上,字符集很少是服务器范围(甚至数据库范围)的设置。不同的表,甚至不同的列都可能需要不同的字符集,而且两者都可以在创建表时指定。

1
2
3
4
5
6
CREATE TABLE mytable
(
columnn1 INT,
columnn2 VARCHAR(10)
) DEFAULT CHARACTER SET hebrew
COLLATE hebrew_general_ci;
  • 如果指定CHARACTER SETCOLLATE两者,则使用这些值。
  • 如果只指定CHARACTER SET,则使用此字符集及其默认的校对(如SHOW CHARACTER SET的结果中所示)。
  • 如果既不指定CHARACTER SET,也不指定COLLATE,则使用数据库默认。

MySQL还允许对每个列设置它们:

1
2
3
4
5
6
7
CREATE TABLE mytable
(
columnn1 INT,
columnn2 VARCHAR(10),
columnn3 VARCHAR(10) CHARACTER SET latin1 COLLATE latin1_general_ci
) DEFAULT CHARACTER SET hebrew
COLLATE hebrew_general_ci;

校对在对用ORDER BY子句检索出来的数据排序时起重要的作用。也可以在SELECT语句中排序时指定校对:

1
2
SELECT * FROM customers
ORDER BY lastname,firstname COLLATE latin1_general_cs;

上面的SELECT语句演示了在通常不区分大小写的表上进行区分大小写搜索的一种技术。当然,反过来也是可以的。

除了这里看到的在ORDER BY子 句中使用以外,COLLATE还可以用于GROUP BY、HAVING、聚集函数、别名等。

值得注意的是,如果绝对需要,串可以在字符集之间进行转换。为此,使用Cast()或Convert()函数:

  • 类型转换
    • cast(expr AS type)
    • convert(expr, type)
  • 编码转换
    • cast(string AS CHAR[(N)] CHARACTER SET charset_name)
    • convert(expr USING transcoding_name)convert(string, CHAR[(N)] CHARACTER SET charset_name

安全管理

应该严肃对待root登录的使用。仅在绝对需要时使用它(或许在你不能登录其他管理账号时使用)。不应该在日常的MySQL操作中使用root。

mysql数据库有一个名为user的表,它包含所有用户账号。user表有一个名为user的列,它存储用户登录名。

1
2
3
-- 获取所有用户账号列表--
USE mysql;
SELECT user FROM user;

用户定义为user@host:MySQL的权限用用户名和主机名结合定义。如果不指定主机名,则使用默认的主机名%(授予用户访问权限而不管主机名)。**%代表所有ip段都可以使用这个用户,也可以指定host为某个ip或ip段,这样会仅允许在指定的ip主机使用该数据库用户**。

为了创建一个新用户账号,使用CREATE USER语句。

1
2
-- 创建一个新用户--
CREATE USER ben IDENTIFIED BY 'password';

CREATE USER创建一个新用户账号。在创建用户账号时不一定需要口令,不过这个例子用IDENTIFIED BY 给出了口令。

  • IDENTIFIED BY指定的口令为纯文本,MySQL将在保存到user表之前对其进行加密。为了作为散列值指定口令,使用IDENTIFIED BY PASSWORD。
  • 使用GRANT或INSERT:GRANT语句(稍后介绍)也可以创建用户账号,但一般来说CREATE USER是最清楚和最简单的句子。此外,也可以通过直接插入行到user表来增加用户,不过为安全起见,一般不建议这样做(不建议插入)。MySQL用来存储用户账号信息的表(以及表模式等)极为重要,对它们的任何毁坏都可能严重地伤害到MySQL服务器。
1
2
3
4
5
-- 重命名一个账号--
RENAME USER ben TO bforta;

-- 删除用户--
DROP USER bforta;

在创建用户账号后,必须接着分配访问权限。新创建的用户账号没有访问权限。它们能登录MySQL,但不能看到数据,不能执行任何数据库操作。为看到赋予用户账号的权限,使用SHOW GRANTS FOR:

1
2
-- 查看用户权限--
SHOW GRANTS FOR bforta;

为设置权限,使用GRANT语句。GRANT要求你至少给出以下信息:

  • 要授予的权限;
  • 被授予访问权限的数据库或表;
  • 用户名。
1
2
3
4
5
-- 设置单个权限--
GRANT SELECT ON some_database.* TO bforta;

-- 设置多个权限--
GRANT SELECT,INSERT ON some_database.* TO bforta;

此GRANT允许用户在some_database.*(some_database数据库的所有表)上使用SELECT(INSERT)。通过只授予SELECT访问权限,用户bforta对some_database数据库中的所有数据具有只读访问权限。

GRANT的反操作为REVOKE,用它来撤销特定的权限(被撤销的访问权限必须存在,否则会出错。):

1
REVOKE SELECT ON some_database.* FROM bforta;

GRANT和REVOKE可在几个层次上控制访问权限:

  • 整个服务器,使用GRANT ALL和REVOKE ALL;
  • 整个数据库,使用ON database.*;
  • 特定的表,使用ON database.table;
  • 特定的列;
  • 特定的存储过程。

下面列出可以授予或撤销的每个权限(前面是语句,后面是能拥有的功能):

  • ALL :除GRANT OPTION外的所有权限
  • ALTER :使用ALTER TABLE
  • ALTER ROUTINE :使用ALTER PROCEDURE和DROP PROCEDURE
  • CREATE :使用CREATE TABLE
  • CREATE ROUTINE :使用CREATE PROCEDURE
  • CREATE TEMPORARY TABLES:使用CREATE TEMPORARY TABLE
  • CREATE USER :使用CREATE USER、DROP USER、RENAME USER和REVOKE ALL PRIVILEGES
  • CREATE VIEW :使用CREATE VIEW
  • DELETE :使用DELETE
  • DROP :使用DROP TABLE
  • EXECUTE :使用CALL和存储过程
  • FILE :使用SELECT INTO OUTFILE和LOAD DATA INFILE
  • GRANT OPTION :使用GRANT和REVOKE
  • INDEX :使用CREATE INDEX和DROP INDEX
  • INSERT :使用INSERT
  • LOCK TABLES :使用LOCK TABLES
  • PROCESS :使用SHOW FULL PROCESSLIST
  • RELOAD :使用FLUSH
  • REPLICATION CLIENT :服务器位置的访问
  • REPLICATION SLAVE :由复制从属使用
  • SELECT :使用SELECT
  • SHOW DATABASES :使用SHOW DATABASES
  • SHOW VIEW :使用SHOW CREATE VIEW
  • SHUTDOWN :使用mysqladmin shutdown(用来关闭MySQL)
  • SUPER :使用CHANGE MASTER、KILL、LOGS、PURGE、MASTER和SET GLOBAL。还允许mysqladmin调试登录
  • UPDATE :使用UPDATE
  • USAGE :无访问权限

在使用GRANT和REVOKE时,用户账号必须存在,但对所涉及的对象没有这个要求。这允许管理员在创建数据库

和表之前设计和实现安全措施。这样做的副作用是,当某个数据库或表被删除时(用DROP语句),相关的访问权限仍然存在。而且,如果将来重新创建该数据库或表,这些权限仍然起作用

1
2
-- 更改口令(密码)--
SET PASSWORD FOR bforta = Passord('new password');

SET PASSWORD更新用户口令。新口令必须传递到Password()函数进行加密。

在不指定用户名时(没有FOR bforta),SET PASSWORD更新当前登录用户的口令。

数据库维护

像所有数据一样,MySQL的数据也必须经常备份。由于MySQL数据库是基于磁盘的文件,普通的备份系统和例程就能备份MySQL的数据。但是,由于这些文件总是处于打开和使用状态,普通的文件副本备份不一定总是有效。

下面列出这个问题的可能解决方案:

  • 使用命令行实用程序mysqldump转储所有数据库内容到某个外部文件。在进行常规备份前这个实用程序应该正常运行,以便能正确地备份转储文件。
  • 可用命令行实用程序mysqlhotcopy从一个数据库复制所有数据(并非所有数据库引擎都支持这个实用程序)。
  • 可以使用MySQL的BACKUP TABLE或SELECT INTO OUTFILE转储所有数据到某个外部文件。这两条语句都接受将要创建的系统文件名,此系统文件必须不存在,否则会出错。数据可以用RESTORETABLE来复原。

首先刷新未写数据:为了保证所有数据被写到磁盘(包括索引数据),可能需要在进行备份前使用FLUSH TABLES语句。

MySQL提供了一系列的语句,可以(应该)用来保证数据库正确和正常运行。

  • ANALYZE TABLE,用来检查表键是否正确,返回如下表的一些状态信息

    1
    ANALYZE TABLE orders;
  • CHECK TABLE用来针对许多问题对表进行检查。在MyISAM表上还对索引进行检查。CHECK TABLE支持一系列的用于MyISAM表的方式。CHANGED检查自最后一次检查以来改动过的表。EXTENDED执行最彻底的检查,FAST只检查未正常关闭的表,MEDIUM检查所有被删除的链接并进行键检验,QUICK只进行快速扫描。

    1
    CHECK TABLE orders,orderitems;

在排除系统启动问题时,首先应该尽量用手动启动服务器。MySQL服务器自身通过在命令行上执行mysqld启动。下面是几个重要的mysqld命令行选项:

  • –help显示帮助——一个选项列表;
  • –safe-mode装载减去某些最佳配置的服务器;
  • –verbose显示全文本消息(为获得更详细的帮助消息与–help联合使用);
  • –version显示版本信息然后退出。

MySQL维护管理员依赖的一系列日志文件。主要的日志文件有以下几种。

  • 错误日志。它包含启动和关闭问题以及任意关键错误的细节。此日志通常名为hostname.err,位于data目录中。此日志名可用–log-error命令行选项更改。
  • 查询日志。它记录所有MySQL活动,在诊断问题时非常有用。此日志文件可能会很快地变得非常大,因此不应该长期使用它。此日志通常名为hostname.log,位于data目录中。此名字可以用–log命令行选项更改。
  • 二进制日志。它记录更新过数据(或者可能更新过数据)的所有语句。此日志通常名为hostname-bin,位于data目录内。此名字可以用–log-bin命令行选项更改。注意,这个日志文件是MySQL 5中添加的,以前的MySQL版本中使用的是更新日志。
  • 缓慢查询日志。顾名思义,此日志记录执行缓慢的任何查询。这个日志在确定数据库何处需要优化很有用。此日志通常名为hostname-slow.log ,位于 data 目录中。此名字可以用–log-slow-queries命令行选项更改。

在使用日志时,可用FLUSH LOGS语句来刷新和重新开始所有日志文件。

改善性能

  • 首先,MySQL(与所有DBMS一样)具有特定的硬件建议。在学习和研究MySQL时,使用任何旧的计算机作为服务器都可以。但对用于生产的服务器来说,应该坚持遵循这些硬件建议。
  • 一般来说,关键的生产DBMS应该运行在自己的专用服务器上。
  • MySQL是用一系列的默认设置预先配置的,从这些设置开始通常是很好的。但过一段时间后你可能需要调整内存分配、缓冲区大小等。(为查看当前设置,可使用SHOW VARIABLES;和SHOW STATUS;。)
  • MySQL一个多用户多线程的DBMS,换言之,它经常同时执行多个任务。如果这些任务中的某一个执行缓慢,则所有请求都会执行缓慢。如果你遇到显著的性能不良,可使用SHOW PROCESSLIST显示所有活动进程(以及它们的线程ID和执行时间)。你还可以用KILL命令终结某个特定的进程(使用这个命令需要作为管理员登录)。
  • 总是有不止一种方法编写同一条SELECT语句。应该试验联结、并、子查询等,找出最佳的方法。
  • 使用EXPLAIN语句让MySQL解释它将如何执行一条SELECT语句。(在 select 语句之前增加explain 关键字,MySQL 会在查询上设置一个标记,执行查询会返回执行计划的信息,并不会执行这条SQL。)
  • 一般来说,存储过程执行得比一条一条地执行其中的各条MySQL语句快
  • 应该总是使用正确的数据类型
  • 决不要检索比需求还要多的数据。换言之,**不要用SELECT ***(除非你真正需要每个列)。
  • 有的操作(包括INSERT)支持一个可选的DELAYED关键字,如果使用它,将把控制立即返回给调用程序,并且一旦有可能就实际执行该操作。
  • 在导入数据时,应该关闭自动提交。你可能还想删除索引(包括FULLTEXT索引),然后在导入完成后再重建它们。
  • 必须索引数据库表以改善数据检索的性能。确定索引什么不是一件微不足道的任务,需要分析使用的SELECT语句以找出重复的WHERE和ORDER BY子句。如果一个简单的WHERE子句返回结果所花的时间太长,则可以断定其中使用的列(或几个列)就是需要索引的对象
  • 你的SELECT语句中有一系列复杂的OR条件吗?通过使用多条SELECT语句和连接它们的UNION语句,你能看到极大的性能改进。
  • 索引改善数据检索的性能,但损害数据插入、删除和更新的性能。如果你有一些表,它们收集数据且不经常被搜索,则在有必要之前不要索引它们。(索引可根据需要添加和删除。)
  • LIKE很慢。一般来说,最好是使用FULLTEXT而不是LIKE。
  • 数据库是不断变化的实体。一组优化良好的表一会儿后可能就面目全非了。由于表的使用和内容的更改,理想的优化和配置也会改变。

关于索引:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
原文链接:https://blog.csdn.net/Weixiaohuai/article/details/109696261

数据库索引是为了提高查询速度而对表字段附加的一种标识。简单来说,索引其实是一种数据结构。数据库的索引类似于书籍的索引。在书籍中,索引允许用户不必翻阅完整个书就能迅速地找到所需要的信息。在数据库中,索引也允许数据库程序迅速地找到表中的数据,而不必扫描整个数据库。

首先我们需要明白为什么索引会提高查询速度,数据库在执行一条SQL语句的时候,默认扫描方式是根据搜索条件进行全表扫描,遇到匹配条件的就加入搜索结果集合。如果我们对某一字段增加索引,查询时就会先去索引列表中一次定位到特定值的行数,大大减少遍历匹配的行数,所以数据库索引能明显提高查询的速度。

下面列举几种适合建立索引的情况:

1.经常在where条件中作为查询条件的字段可以建立索引;
2.外键关联列可以建立索引;
3.order by排序后面的字段可以建立索引;
4.group by分组后的字段可以建立索引;

当然,并不是所有情况下都适合建立索引,如下几种情况就不太适合建立索引:

1.经常增、删、改的字段不适合建立索引,每次执行,索引需重新建立;
2.数据过滤性很差的字段不适合建立索引,如性别字段;
3.当表数据量过少的时候不太适合建立索引,因为索引占用存储空间;
  • 索引是对数据库表中一列或多列的值进行排序的一种结构,使用索引可快速访问数据库表中的特定信息。如果想按特定职员的姓来查找他或她,则与在表中搜索所有的行相比,索引有助于更快地获取信息。
  • 索引的一个主要目的就是加快检索表中数据的方法,亦即能协助信息搜索者尽快的找到符合限制条件的记录ID的辅助数据结构。
  • 索引是对数据库表中一个或多个列(例如,employee 表的姓名 (name) 列)的值进行排序的结构。
  • 例如这样一个查询:select * from table1 where id=10000。如果没有索引,必须遍历整个表,直到ID等于10000的这一行被找到为止;有了索引之后(必须是在ID这一列上建立的索引),即可在索引中查找。由于索引是经过某种算法优化过的,因而查找次数要少的多。可见,索引是用来定位的。
  • 从数据搜索实现的角度来看,索引也是另外一类文件/记录,它包含着可以指示出相关数据记录的各种记录。其中,每一索引都有一个相对应的搜索码,字符段的任意一个子集都能够形成一个搜索码。这样,索引就相当于所有数据目录项的一个集合,它能为既定的搜索码值的所有数据目录项提供定位所需的各种有效支持

数据类型

串数据类型

  • CHAR :1~255个字符的定长串。它的长度必须在创建时指定,否则MySQL假定为CHAR(1)
  • ENUM :接受最多64 K个串组成的一个预定义集合的某个串
  • LONGTEXT :与TEXT相同,但最大长度为4 GB
  • MEDIUMTEXT :与TEXT相同,但最大长度为16 K
  • SET:接受最多64个串组成的一个预定义集合的零个或多个串
  • TEXT :最大长度为64 K的变长文本
  • TINYTEXT :与TEXT相同,但最大长度为255字节
  • VARCHAR :长度可变,最多不超过255字节。如果在创建时指定为VARCHAR(n),则可存储0到n个字符的变长串(其中n≤255)

不管使用何种形式的串数据类型,串值都必须括在引号内(通常单引号更好)。

当数值不是数值时:你可能会认为电话号码和邮政编码应该存储在数值字段中(数值字段只存储数值数据),但是,这样 做却是不可取的。如果在数值字段中存储邮政编码01234,则保存的将是数值1234,实际上丢失了一位数字。

需要遵守的基本规则是:如果数值是计算(求和、平均等)中使用的数值,则应该存储在数值数据类型列中。如果作为字符串(可能只包含数字)使用,则应该保存在串数据类型列中。

数值数据类型

有符号或无符号:所有数值数据类型(除BIT和BOOLEAN外)都可以有符号或无符号。有符号数值列可以存储正或负的数值,无符号数值列只能存储正数。默认情况为有符号,但如果你知道自己不需要存储负值,可以使用UNSIGNED关键字,这样做将允许你存储两倍大小的值。

  • BIT :位字段,1~64位。(在MySQL 5之前,BIT在功能上等价于TINYINT
  • BIGINT :整数值,支持9223372036854775808~9223372036854775807(如果是UNSIGNED,为0~18446744073709551615)的数
  • BOOLEAN(或BOOL) :布尔标志,或者为0或者为1,主要用于开/关(on/off)标志
  • DECIMAL(或DEC) :精度可变的浮点值
  • DOUBLE :双精度浮点值
  • FLOAT :单精度浮点值
  • INT(或INTEGER) :整数值,支持-2147483648~2147483647(如果是UNSIGNED, 为0~4294967295)的数
  • MEDIUMINT :整数值,支持-8388608~8388607(如果是UNSIGNED,为0~16777215)的数
  • REAL :4字节的浮点值
  • SMALLINT:整数值,支持-32768~32767(如果是UNSIGNED,为0~65535)的数
  • TINYINT :整数值,支持-128~127(如果为UNSIGNED,为0~255)的数

与串不一样,数值不应该括在引号内。

存储货币数据类型:MySQL中没有专门存储货币的数据类型,一般情况下使用DECIMAL(8, 2):总长度为8,小数位数为2位的数值,整数有效位为6。

日期和时间数据类型

  • DATE :表示1000-01-01~9999-12-31的日期,格式为YYYY-MM-DD
  • DATETIME :DATE和TIME的组合
  • TIMESTAMP :功能和DATETIME相同(但范围较小)
  • TIME :格式为HH:MM:SS
  • YEAR :用2位数字表示,范围是70(1970年)~69(2069年),用4位数字表示,范围是1901年~2155年

二进制数据类型

  • BLOB :Blob最大长度为64 KB
  • MEDIUMBLOB :Blob最大长度为16 MB
  • LONGBLOB :Blob最大长度为4 GB
  • TINYBLOB :Blob最大长度为255字节

后记

写这篇博客比看书花的时间还要久 (;′⌒`)

书上的内容并不太难,实际上,在写这篇博客的过程中,对书上的内容也有了自己的思路,因此在内容规划上有些许不同,不过章节上为了图方便就是按顺序来的。代码基本都自己又敲了一遍,有新的体会,其中也写了许多自己的见解和注释,补充了一些内容,但主要还是当日后复习用。

2022-08-01

重返C++,先从STL入手。这里会记录一些重点。

follow 侯捷大师的《STL源码剖析》

声明

博客大部分内容都来源于《STL源码剖析》这本书(copy了个人认为重要的部分,包括代码),对于一些不容易明白的地方会查找其他资料进行补充。

由于电子的文字版只有前四章,因此后面的章节只能截扫描版的图。由于cdn加速不稳定,图片可能要用魔法才能加载(′⌒`)

概念与基础

STL六大组件

1.容器(containers):各种数据结构,如vector, list, deque, set, map,用来存放数据。从实作的角度看,STL 容器是一种 class template。就体积而言,这一部份很像冰山在海面下的比率。

2.算法(algorithms):各种常用算法如sort, search, copy, erase…。从实作的角度看,STL 算法是一种 function template。

3.迭代器(iterators):扮演容器与算法之间的胶着剂,是所谓的「泛型指标」。共有五种类型,以及其它衍生变化。从实作的角度看,迭代器是一种将operator*, operator->, operator++, operator–等指标相关操作予以多载化的 class template。所有STL容器都附带有自己专属的迭代器—是的,只有容器设计者才知道如何巡访自己的元素。原生指标(native pointer)也是一种迭代器。

4.仿函式(functors):行为类似函式,可做为算法的某种策略(policy)。从实作的角度看,仿函式是一种重载了 operator()的 class 或class template。一般函式指标可视为狭义的仿函式。

5.配接器(adapters):一种用来修饰容器(containers)或仿函式(functors)或迭代器(iterators)接口的东西。例如 STL 提供的 queue 和stack,虽然看似容器,其实只能算是一种容器配接器,因为它们的底部完全借重 deque,所有动作都由底层的 deque供应。改变functor接口者,称为function adapter,改变container接口者,称为container adapter,改变iterator界面者,称为iterator adapter。配接器的实作技术很难一言以蔽之,必须逐一分析。

6.配置器(allocators):负责空间配置与管理,详见第 2 章。从实作的角度看,配置器是一个实现了动态空间配置、空间管理、空间释放的 class template。

STL六大组件的交互关系:Container透过Allocator取得数据储存空间,Algorithm透过Iterator存取Container内容,Functor可以协助 Algorithm完成不同的策略变化,Adapter可以修饰或套接 Functor。

image-20220620143742620

SGI STL文件分布与简介

概略可分为五组:

  • C++标准规范下的 C 头文件(无扩展名),例如cstdio, cstdlib, cstring…

  • C++标准链接库中不属于 STL范畴者,例如 stream, string…相关文件。

  • STL标准头文件(无扩展名),例如vector, deque, list, map, algorithm, functional…

  • C++ Standard 定案前,HP 所规范的 STL 头文件,例如vector.h, deque.h, list.h, map.h, algo.h, function.h…

  • SGI STL 内部文件(STL 真正实作于此),例如stl_vector.h, stl_deque.h, stl_list.h, stl_map.h, stl_algo.h, stl_function.h…

SGI STL 的编译器组态设定(configuration)

不同的编译器对C++语言的支持程度不尽相同。做为一个希望具备广泛移植能力的链接库,SGI STL 准备了一个环境组态文件<stl_config.h>,其中定义许多常数,标示某些状态的成立与否。所有 STL 头文件都会直接或间接含入这个组态文件,并以条件式写法,让前处理器(pre-processor)根据各个常数决定取舍哪一段程序码。

<stl_config.h>文件起始处有一份常数定义说明,然后即针对各家不同的编译器以及可能的不同版本,给予常数设定。

这里先介绍<stl_config.h>中的预定义组态配置项

  • __STL_STATIC_TEMPLATE_MEMBER_BUG

    如果编译器无法处理static member of template classes(模板类静态成员)就定义

  • __STL_CLASS_PARTIAL_SPECIALIZATION

    如果编译器支持 partial specialization of class templates(模板类偏特化)就定义。

    偏特化:模板为什么要特化,因为编译器认为,对于特定的类型,如果你能对某一功能更好的实现,那么就该听你的。

    模板分为类模板函数模板,特化分为全特化与偏特化。全特化就是限定死模板实现的具体类型,偏特化就是如果这个模板有多个类型,那么只限定其中的一部分。

    使用template定义类时可以使用const *等做特殊设计。

    1
    2
    3
    4
    5
    template <class T1 , class T2>//泛型
    struct test{....};

    template <class T>
    struct test<T*, const T* > {....};//偏特化
  • __STL_FUNCTION_TMPL_PARTIAL_ORDER

    如果编译器支持partial ordering of function templates或者说partial specialization of function templates就定义,可以理解为对函数模板的重载的支持

    对于一个函数模板,如果定义了另一个函数模板且函数名相同,会根据template的参数表进行调用,要注意的是,第二个函数模板需要知道参数,可以在函数后面加上例如,max<T,T>或者函数之前声明类struct<T,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
    25
    26
    27
    28
    29
    30
    template<typename T1, typename T2>
    struct Bar
    {
    void operator()(T1 const& t1, T2 const& t2)
    {
    std::cerr << "In Bar<T1, T2>(" << t1 << ", " << t2 << ")\n";
    }
    };
    template<typename T2>
    struct Bar<int, T2>//声明<T1,T2>
    {
    void operator()(int t1, T2 const& t2)
    {
    std::cerr << "In Bar<int, T2>(" << t1 << ", " << t2 << ")\n";
    }
    };
    /* 下列代码也可以
    template<typename T2>
    void operator()<int, T2>(int t1, T2 const& t2)
    {
    std::cerr << "In Bar<int, T2>(" << t1 << ", " << t2 << ")\n";
    }
    */
    template<typename T1, typename T2>
    void bar(T1 const& t1, T2 const& t2)
    {
    Bar<T1, T2> b;
    b(t1, t2);
    }

  • __STL_MEMBER_TEMPLATES

    如果编译器支持template members of classes 就定义,看英文就知道,模板类中嵌套模板

    1
    2
    3
    4
    5
    6
    7
    8
    template <class T>
    class vector{
    public:
    template <class TT>
    void test(TT a,TT b) {
    cout << a << ' ' << b << endl;
    }
    };
  • __STL_LIMITED_DEFAULT_TEMPLAES

    用到前一个模板的模板形参的某一个具现体作为当前模板的模板形参的默认值

    1
    2
    template <class T, class Se = queue<T> >
    class test {....};
  • __STL_NON_TYPE_TMPL_PARAM_BUG

    测试类模板是否使用非类型模板参数(non-type template parameters),或着是否template 可以使用无参数类型模板

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    template<int size>
    class CTest
    {
    int m_data[size];
    };

    void main()
    {
    CTest<10> obj;
    }

    非类型模板:非类型模板参数(nontype template parameters), 可以使用整型类型(integral type),指针(pointer)或者是引用(reference);绑定非类型整数形参(nontype integral parameter) 的 实参(argument) 必须是常量表达式(constant expression, constexpr);不能把普通的局部对象或者动态对象 绑定指针或引用的非类型形参, 可以使用全局类型进行绑定。

  • __STL_NULL_TMPL_ARGS

    友元约束模板:可以依据之前对非友元函数的定义来对友元函数进行约束

    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
    template<class T,class Sequence>
    class stack;

    template<class T,class Sequence>
    bool operator==(const stack<T,Sequence>& x,const stack<T,Sequence>& y);

    template<class T,class Sequence=deque<T> >
    class stack
    {
    //friend bool operator==<T>(const stack<T>&,const stack<T>&);
    //下面的都是等价于上面的
    //friend bool operator== <T>(const stack&,const stack&);
    friend bool operator== <>(const stack&,const stack&);
    Sequence c;
    };
    template<class T,class Sequence>
    bool operator==(const stack<T,Sequence> &x,const stack<T,Sequence> &y)
    {
    return cout<<"operator=="<<'\t';
    }
    int main()
    {
    stack<int> x;
    stack<int> y;
    cout<<(x==y)<<endl;

    stack<char> y1;
    // cout<<(x==y1)<<endl;
    }
  • __STL_TEMPLATE_NULL

    即 template <> 显式的模板特化,对template进行具体化,在定义参数时就可以使用具体化的定义。函数同理。

    1
    2
    3
    4
    5
    6
    template<class T> struct test {....};
    template<> struct test<int>{.....};

    template<class Any>
    void swap(Any &,Any &b){......;}
    template <> void swap<int>(int &,int &){......;}

以下是 GNU C++ 2.91.57 <stl_config.h>的完整内容:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
#ifndef __STL_CONFIG_H 
# define __STL_CONFIG_H
//文件所做的事情:
// (1) 如果编译器没有定义 bool, true, false,就定义它们
// (2) 如果编译器的标准链接库᳾支持 drand48()函式,就定义 __STL_NO_DRAND48
// (3) 如果编译器无法处理 static members of template classes,就定义
// __STL_STATIC_TEMPLATE_MEMBER_BUG
// (4) 如果编译器᳾支持关键词 typename,就将'typename'定义为一个 null macro.
// (5) 如果编译器支持 partial specialization of class templates,就定义
// __STL_CLASS_PARTIAL_SPECIALIZATION.
// (6) 如果编译器支持 partial ordering of function templates(亦称为
// partial specialization of function templates),就定义
// __STL_FUNCTION_TMPL_PARTIAL_ORDER
// (7) 如果编译器允许我们在呼叫一个 function template时可以明白指定其
// template arguments,就定义__STL_EXPLICIT_FUNCTION_TMPL_ARGS
// (8) 如果编译器支持 template members of classes,就定义
// __STL_MEMBER_TEMPLATES.
// (9) 如果编译器不支持关键词 explicit,就定义'explicit'为一个 null macro.
// (10) 如果编译器无法根据前一个 template parameters设定下一个 template
// parameters 的默认值,就定义__STL_LIMITED_DEFAULT_TEMPLATES
// (11) 如果编译器针对 non-type template parameters 执行 function template
// 的自变量推导(argument deduction)时有问题,就定义
// __STL_NON_TYPE_TMPL_PARAM_BUG.
// (12) 如果编译器无法支持迭代器的 operator->,就定义
// __SGI_STL_NO_ARROW_OPERATOR
// (13) 如果编译器(在你所选择的模式中)支持 exceptions,就定义
// __STL_USE_EXCEPTIONS
// (14) 定义__STL_USE_NAMESPACES 可使我们自动获得 using std::list;之类的叙句
// (15) 如果本链接库由 SGI编译器来编译,而且使用者并未选择 pthreads
// 或其它 threads,就定义__STL_SGI_THREADS.
// (16) 如果ᴀ链接库由一个 WIN32 编译器编译,并且在多绪模式下,就定义
// __STL_WIN32THREADS
// (17) 适当地定义与 namespace相关的 macros 如 __STD, __STL_BEGIN_NAMESPACE。
// (18) 适当地定义 exception 相关的 macros 如 __STL_TRY, __STL_UNWIND。
// (19) 根据__STL_ASSERTIONS是否定义,将 __stl_assert 定义为一个
// 测试动作或一个 null macro。
#ifdef _PTHREADS
# define __STL_PTHREADS
#endif
# if defined(__sgi) && !defined(__GNUC__)
//使用 SGI STL但却不是使用 GNU C++
# if !defined(_BOOL) //没有BOOL就定义
# define __STL_NEED_BOOL
# endif
# if !defined(_TYPENAME_IS_KEYWORD)
# define __STL_NEED_TYPENAME
# endif
# ifdef _PARTIAL_SPECIALIZATION_OF_CLASS_TEMPLATES
# define __STL_CLASS_PARTIAL_SPECIALIZATION
# endif
# ifdef _MEMBER_TEMPLATES
# define __STL_MEMBER_TEMPLATES
# endif
# if !defined(_EXPLICIT_IS_KEYWORD)
# define __STL_NEED_EXPLICIT
# endif
# ifdef __EXCEPTIONS
# define __STL_USE_EXCEPTIONS
# endif
# if (_COMPILER_VERSION >= 721) && defined(_NAMESPACES)
# define __STL_USE_NAMESPACES
# endif
# if !defined(_NOTHREADS) && !defined(__STL_PTHREADS)
# define __STL_SGI_THREADS
# endif
# endif
# ifdef__GNUC__
# include <_G_config.h>
# if __GNUC__ < 2 || (__GNUC__ == 2 && __GNUC_MINOR__ < 8)
# define __STL_STATIC_TEMPLATE_MEMBER_BUG
# define __STL_NEED_TYPENAME
# define __STL_NEED_EXPLICIT
# else // 这里可看出 GNUC 2.8+ 的能力
# define __STL_CLASS_PARTIAL_SPECIALIZATION
# define __STL_FUNCTION_TMPL_PARTIAL_ORDER
# define __STL_EXPLICIT_FUNCTION_TMPL_ARGS
# define __STL_MEMBER_TEMPLATES
# endif
/* glibc pre 2.0 is very buggy. We have to disable thread for it.
It should be upgraded to glibc 2.0 or later. */
# if !defined(_NOTHREADS) && __GLIBC__ >= 2 && defined(_G_USING_THUNKS)
# define __STL_PTHREADS
# endif
# ifdef __EXCEPTIONS
# define __STL_USE_EXCEPTIONS
# endif
# endif
# if defined(__SUNPRO_CC)
# define __STL_NEED_BOOL
# define __STL_NEED_TYPENAME
# define __STL_NEED_EXPLICIT
# define __STL_USE_EXCEPTIONS
# endif
# if defined(__COMO__)
# define __STL_MEMBER_TEMPLATES
# define __STL_CLASS_PARTIAL_SPECIALIZATION
# define __STL_USE_EXCEPTIONS
# define __STL_USE_NAMESPACES
# endif
//侯捷注:VC6的版ᴀ号码是 1200
# if defined(_MSC_VER)
# if _MSC_VER > 1000
# include <yvals.h>
# else
//此文件在 MSDEV\VC98\INCLUDE
# define __STL_NEED_BOOL
# endif
# define __STL_NO_DRAND48
# define __STL_NEED_TYPENAME
# if _MSC_VER < 1100
# define __STL_NEED_EXPLICIT
# endif
# define __STL_NON_TYPE_TMPL_PARAM_BUG
# define __SGI_STL_NO_ARROW_OPERATOR
# ifdef _CPPUNWIND
# define __STL_USE_EXCEPTIONS
# endif
# ifdef _MT
# define __STL_WIN32THREADS
# endif
# endif
//侯捷注:Inprise Borland C++builder也定义有此常数。
// C++Builder 的表现岂有如下所示这般差劲?
# if defined(__BORLANDC__)
# define __STL_NO_DRAND48
# define __STL_NEED_TYPENAME
# define __STL_LIMITED_DEFAULT_TEMPLATES
# define __SGI_STL_NO_ARROW_OPERATOR
# define __STL_NON_TYPE_TMPL_PARAM_BUG
# ifdef _CPPUNWIND
# define __STL_USE_EXCEPTIONS
# endif
# ifdef __MT__
# define __STL_WIN32THREADS
# endif
# endif
# if defined(__STL_NEED_BOOL)
typedef int bool;
# define true 1
# define false 0
# endif
# ifdef __STL_NEED_TYPENAME
23
# define typename//侯捷:难道不该 #define typename class 吗?
# endif
# ifdef __STL_NEED_EXPLICIT
# define explicit
# endif
# ifdef__STL_EXPLICIT_FUNCTION_TMPL_ARGS
# define __STL_NULL_TMPL_ARGS<>
# else
# define __STL_NULL_TMPL_ARGS
# endif
# ifdef__STL_CLASS_PARTIAL_SPECIALIZATION
# define __STL_TEMPLATE_NULLtemplate<>
# else
# define __STL_TEMPLATE_NULL
# endif
// __STL_NO_NAMESPACES is a hook so that users can disable namespaces
// without having to edit library headers.
# if defined(__STL_USE_NAMESPACES) && !defined(__STL_NO_NAMESPACES)
# define __STD std
# define __STL_BEGIN_NAMESPACE namespacestd {
# define __STL_END_NAMESPACE }
# define __STL_USE_NAMESPACE_FOR_RELOPS
# define __STL_BEGIN_RELOPS_NAMESPACE namespace std {
# define __STL_END_RELOPS_NAMESPACE }
# define __STD_RELOPS std
# else
# define __STD
# define __STL_BEGIN_NAMESPACE
# define __STL_END_NAMESPACE
# undef __STL_USE_NAMESPACE_FOR_RELOPS
# define __STL_BEGIN_RELOPS_NAMESPACE
# define __STL_END_RELOPS_NAMESPACE
# define __STD_RELOPS
# endif
# ifdef __STL_USE_EXCEPTIONS
# define __STL_TRY try
# define __STL_CATCH_ALL catch(...)
# define __STL_RETHROWthrow
# define __STL_NOTHROWthrow()
# define __STL_UNWIND(action) catch(...) { action; throw; }
# else
# define __STL_TRY
# define __STL_CATCH_ALL if (false)
# define __STL_RETHROW
# define __STL_NOTHROW
# define __STL_UNWIND(action)
# endif
#ifdef __STL_ASSERTIONS
# include <stdio.h>
# define __stl_assert(expr) \
if (!(expr)) {fprintf(stderr, "%s:%d STL assertion failure: %s\n", \
__FILE__, __LINE__,# expr);abort(); }
#else
# define __stl_assert(expr)
#endif
#endif /* __STL_CONFIG_H */
// Local Variables:
// mode:C++
// End:

下面这个小程序,用来测试 GCC 的常数设定:

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
// file: 1config.cpp 
// test configurations defined in <stl_config.h>
#include <vector> // which included <stl_algobase.h>,
// and then <stl_config.h>
#include <iostream>
using namespace std;
int main()
{
# if defined(__sgi)
cout << "__sgi" << endl; // none!
# endif
# if defined(__GNUC__)
cout << "__GNUC__" << endl; // __GNUC__
cout << __GNUC__ << ' ' << __GNUC_MINOR__ << endl; // 2 91
// cout << __GLIBC__ << endl; // __GLIBC__ undeclared
# endif
// case 2
#ifdef __STL_NO_DRAND48
cout << "__STL_NO_DRAND48 defined" << endl;
#else
cout << "__STL_NO_DRAND48 undefined" << endl;
#endif
// case 3
#ifdef __STL_STATIC_TEMPLATE_MEMBER_BUG
cout << "__STL_STATIC_TEMPLATE_MEMBER_BUG defined" << endl;
#else
cout << "__STL_STATIC_TEMPLATE_MEMBER_BUG undefined" << endl;
#endif
// case 5
#ifdef __STL_CLASS_PARTIAL_SPECIALIZATION
cout << "__STL_CLASS_PARTIAL_SPECIALIZATION defined" << endl;
#else
cout << "__STL_CLASS_PARTIAL_SPECIALIZATION undefined" << endl;
#endif
// case 6
...以下写法类似。详见文件 config.cpp(可自侯捷网站下载)。
}

执行结果如下,由此可窥见 GCC 对各种 C++特性的支持程度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
__GNUC__ 
2 91
__STL_NO_DRAND48 undefined
__STL_STATIC_TEMPLATE_MEMBER_BUG undefined
__STL_CLASS_PARTIAL_SPECIALIZATION defined
__STL_FUNCTION_TMPL_PARTIAL_ORDER defined
__STL_EXPLICIT_FUNCTION_TMPL_ARGS defined
__STL_MEMBER_TEMPLATES defined
__STL_LIMITED_DEFAULT_TEMPLATES undefined
__STL_NON_TYPE_TMPL_PARAM_BUG undefined
__SGI_STL_NO_ARROW_OPERATOR undefined
__STL_USE_EXCEPTIONS defined
__STL_USE_NAMESPACES undefined
__STL_SGI_THREADS undefined
__STL_WIN32THREADS undefined
__STL_NO_NAMESPACES undefined
__STL_NEED_TYPENAME undefined
__STL_NEED_BOOL undefined
__STL_NEED_EXPLICIT undefined
__STL_ASSERTIONS undefined

C++ 一些特殊语法

暂时对象产生与运用

所谓暂时对象(临时对象),就是一种无名对象(unnamed objects)。它的出现如果不在程序员的预期之下(例如任何pass by value动作都会引发copy动作,于是形成一个暂时对象),往往造成效率上的负担。

有时候刻意制造一些暂时对象,却又是使程序干净清爽的技巧。刻意制造暂时对象的方法是,在型别名称之后直接加一对小括号,并可指定初值,例如Shape(3,5)或int(8),其意义相当于唤起相应的constructor不指定物件名称
STL 中将暂时对象技巧用于仿函式(functor)与算法搭配,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// file: 1config-temporary-object.cpp 
//ᴀ例测试仿函式用于 for_each() 的情形
// vc6[o] cb4[o] gcc[o]
#include <vector>
#include <algorithm>
#include <iostream>
using namespace std;
template <typename T>
class print
{
public:
void operator()(const T& elem) // operator() 多载化
{ cout << elem << ' '; }
};
int main()
{
int ia[6] = { 0,1,2,3,4,5 };
vector< int > iv(ia, ia+6);
// print<int>() 是一个暂时对象,不是一个函式呼叫动作
for_each(iv.begin(), iv.end(), print<int>());
}

最后一行便是产生「function template具现体」print的一个暂时对象。这个对象将被传入for_each()之中起作用。当for_each()结束,这个暂时对象也就结束了它的生命。

静态常量整数成员在类内直接初始化

如果 class内含 const static integral data member,那么根据 C++标准规格,可以在class之内直接给予初值。所谓integral泛指所有整数型别,不单只是指int

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// file: 1config-inclass-init.cpp 
// test in-class initialization of static const integral members
// ref. C++ Primer 3/e, p.643
// vc6[x] cb4[o] gcc[o]
#include <iostream>
using namespace std;
template <typename T>
class testClass {
public: // expedient
static const int _datai = 5;
static const long _datal = 3L;
static const char _datac = 'c';
};
int main()
{
cout << testClass<int>::_datai << endl; // 5
cout << testClass<int>::_datal << endl; // 3
cout << testClass<int>::_datac << endl; // c
}

increment/decrement/dereference运算子

increment / dereference 运算子在迭代器的实作上占有非常重要的地位,因为任何一个迭代器都必须实作出前进(increment,operator++ )和取值(dereference, operator*)功能,前者还分为前置式(prefix)和后置式(postfix)两种,有非常规律的写法。有些迭代器具备双向移动功能,那么就必须再提供 decrement 运算子(也分前置式和后置式两种)。

前闭后开区间表示法[)

任何STL 算法都需要获得由一对迭代器(泛型指针)所表示的区间,表示操作范围,这一对所表示的区间是前闭后开的,[first ,lasr) 表示 first 到 last - 1。迭代器 last 所指的是「最后一个元素的下一位置」。这种off by one(偏移一格,或说 pass the end)的标示法,带来许多方便。

function call(函数调用) 运算子(operator())

函式呼叫动作(C++ 语法中的左右小括号)也可以被多载化(重载)。许多STL算法都提供两个版本,一个用于一般状况(例如排序时以递增方式排列),一个用于特殊状况(例如排序时由使用者指定以何种特殊关系进行排列)。像这种情况,需要使用者指定某个条件或某个策略,而条件或策略的背后由一整组动作构成,便需要某种特殊的东西来代表这「一整组动作」。代表「一整组动作」的,当然是函式。过去 C语言时代,欲将函式当做参数传递,唯有透过函式指针(pointer to function,或称 function pointer)才能达成。
但是函数指针使用时有缺点:1. 无法持有自己的状态(所谓区域状态,local states);2.无法达到组件技术中的可配接性(adaptability)—也就是无法再将某些修饰条件加诸于其上而改变其状态。

STL 算法的特殊版本所接受的所谓「条件」或「策略」或「一整组动作」,都以仿函式形式呈现。所谓仿函式(functor)就是使用起来像函式一样的东西。如果你针对某个 class 进行operator() 多载化,它就成为一个仿函式。

仿函数

仿函数(Functor)又称为函数对象(Function Object)是一个能行使函数功能的类。

仿函数的语法几乎和我们普通的函数调用一样,不过作为仿函数的类,都必须重载 operator() 运算符。因为调用仿函数,实际上就是通过类对象调用重载后的 operator() 运算符。

使用:对类进行operator() 进行重载,这个类就是仿函数,可以通过编码使仿函数变为可配接的,对象当做函数名

优点:

  • 仿函数是对象,可以拥有成员函数和成员变量,即仿函数拥有状态(states)
  • 每个仿函数都有自己的类型
  • 仿函数通常比一般函数快(很多信息编译期确定)

仿函数与函数指针相比的优势

  • 仿函数是一个类,是数据以及对数据操作的行为的集合,要成为仿函数必须重载()。函数指针是无法保存数据的,所以仿函数比函数指针功能更强,因为它可以保存数据,这一特性,是函数指针无法比拟的优势。

总结

第一章只是简单介绍了一下stl,包括组件基本内容和组件之间的关系,其实后面具体去了解了也就明白了。而组态部分比较繁杂(个人感觉),是关于编译器对stl支持程度的设定,这部分目前还不知道有什么作用、怎么去学习,因此也就没有具体去看,实际上后面的内容也基本与组态无关。

然后是一些特殊语法,没有讲多细致,后面具体学就行,注意前闭后开这个贯穿全文的条件即可。

空间配置器

以STL 的运用角度而言,空间配置器是最不需要介绍的东西,它总是隐藏在一切组件(更具体地说是指容器,container)的背后,默默工作默默付出。但若以STL 的实作角度而言,第一个需要介绍的就是空间配置器,因为整个STL的操作对象(所有的数值)都存放在容器之内,而容器一定需要配置空间以置放数据。不先掌握空间配置器的原理,难免在观察其它 STL 组件的实作时处处遇到挡路石。

为什么不说allocator是内存配置器而说它是空间配置器呢?因为,空间不一定是内存,空间也可以是磁盘或其它辅助储存媒体。是的,你可以写一个 allocator,直接向硬盘取空间。以下介绍的是 SGI STL 提供的配置器,配置的对象,呃,是的,是内存 。

空间配置器的标准接口

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
//各种type
allocator::value_type
allocator::pointer
allocator::const_pointer
allocator::reference
allocator::const_reference
allocator::size_type
allocator::difference_type

allocator::rebind
//一个巢状的(nested)class template。class rebind<U>拥有唯一成员other,那是一个 typedef,代表allocator<U>。
allocator::allocator()
//default constructor。//默认构造函数
allocator::allocator(const allocator&)
//copy constructor。//拷贝构造函数
template <class U>allocator::allocator(const allocator<U>&)
//泛化的copy constructor。//泛化的拷贝构造
allocator::~allocator()
//default constructor。//析构函数
pointer allocator::address(reference x) const
//传回某个对象的地址。算式a.address(x)等同于&x。//返回某个对象的地址
const_pointer allocator::address(const_reference x) const
//传回某个const对象的地址。算式a.address(x)等同于&x。//返回某个const对象的地址
pointer allocator::allocate(size_type n, cosnt void* = 0)
//配置空间,足以储存n个T对象。第二自变量是个提示。实作上可能会利用它来增进区域性(locality),或完全忽略之。
void allocator::deallocate(pointer p, size_type n)
//归还先前配置的空间。
size_type allocator::max_size() const
//传回可成功配置的最大量。
void allocator::construct(pointer p, const T& x)
//等同于new(const void*) p) T(x)。//构造T对象
void allocator::destroy(pointer p)
//等同于p->~T()。//对象T的析构

设计一个简单的空间配置器 JJ::allocator

根据前述的标准接口,我们可以自行完成一个 功能简单、接口不怎么齐全的allocator如下:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// file: 2jjalloc.h 
#ifndef _JJALLOC_
#define _JJALLOC_
#include <new> // for placement new.
#include <cstddef> // for ptrdiff_t, size_t
#include <cstdlib> // for exit()
#include <climits> // for UINT_MAX
#include <iostream> // for cerr
namespace JJ
{
//使用operator new 分配空间
template <class T>
inline T* _allocate(ptrdiff_t size, T*) {
set_new_handler(0); //注释1
T* tmp = (T*)(::operator new((size_t)(size * sizeof(T))));//注释2
if (tmp == 0) {
cerr << "out of memory" << endl;
exit(1);
}
return tmp;
}
//使用operator delete回收空间
template <class T>
inline void _deallocate(T* buffer) {
::operator delete(buffer);
}
//在指定内存上构造一个对象
template <class T1, class T2>
inline void _construct(T1* p, const T2& value) {
new(p) T1(value); // placement new. invoke ctor of T1.
}
//析构一个对象
template <class T>
inline void _destroy(T* ptr) {
ptr->~T();
}
//遵循allocator的标准定义相关结构
template <class T>
class allocator {
public:
typedef T value_type;
typedef T* pointer;
typedef const T* const_pointer;
typedef T& reference;
typedef const T& const_reference;
typedef size_t size_type;
typedef ptrdiff_tdifference_type;
// rebind allocator of type U
template <class U>
struct rebind {
typedef allocator<U> other;
};
// hint used for locality. ref.[Austern],p189
pointer allocate(size_type n, const void* hint=0) {
return _allocate((difference_type)n, (pointer)0);
}
void deallocate(pointer p, size_type n) { _deallocate(p); }
void construct(pointer p, const T& value) {
_construct(p, value);
}
void destroy(pointer p) {_destroy(p); }
pointer address(reference x) { return (pointer)&x; }
const_pointer const_address(const_reference x) {
return (const_pointer)&x;
}
size_type max_size() const {
return size_type(UINT_MAX/sizeof(T));
}
};
} // end of namespace JJ
#endif // _JJALLOC_
  • 注释1:set_new_handler(0);

    new_handler,顾名思义就是一个处理程序,当程序向内存的分配请求无法满足时将有两种可能:

    1. 抛出异常
    2. 设置一个异常处理函数,这就是所谓的new_handler(类似于中断机制,本质上来说就是一个函数指针)

    当第二种情况发生以后,我们可以通过new_handler删除无用的内存,以及设置新的new_handler,而这个set_new_handler就是来进行设置的。

    set_new_handler(0)主要是为了卸载目前的内存分配异常处理函数,这样一来一旦分配内存失败的话,C++就会强制性抛出std:bad_alloc异常,而不是跑到处理某个异常处理函数去处理。

  • 注释2:*T *tmp=(T*)(::operator new((size_t)(sizesizeof(T)))); **::访问符放到最前面的意思是使用全局版本,这个operator new就得好好说说。

    new 的三种形式

    • 1.new operator (就是我们常用的new)
    • 2.operator new
    • 3.placement new

    我们在程序中使用new的时候,实际上做了两件事情:
    一、申请内存
    二、构造对象
    简单的理解,new完成了一套比较完备的服务,而operator new,只是申请内存,placement new是在申请的内存中进行构造对象,第2、3中形式就是对new的拆分。

简单应用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// file: 2jjalloc.cpp 
// VC6[o], BCB4[o], GCC2.9[x].
#include "jjalloc.h"
#include <vector>
#include <iostream>
using namespace std;
int main()
{
int ia[5] = {0,1,2,3,4};
unsigned int i;
vector<int,JJ::allocator<int> > iv(ia, ia+5);
for(i=0; i<iv.size(); i++)
cout << iv[i] << ' ';
cout << endl;
}

具备次配置力(sub-allocation)的 SGI 空间配置器

SGI STL 的配置器与众不同 , 也与标准规范不同,其名称是alloc而非allocator,而且不接受任何自变量。换句话说如果你要在程序中明白采用SGI 配置器,不能采用标准写法:vector<int,**std::allocator<int>** > iv;// in VC or CB

必须这么写:vector<int,**std::alloc**<int>> iv; // in GCC

SGI STL allocator未能符合标准规格,这个事实通常不会对我们带来困扰,因为通常我们使用预设的空间配置器,很少需要自行指定配置器名称,而SGI STL的每一个容器都已经指定其预设的空间配置器为alloc。

SGI 标准的空间配置器 std::allocator

虽然 SGI 也定义有一个符合部份标准、名为allocator的配置器,但SGI自己从未用过它,也不建议我们使用。主要原因是效率不彰,只把 C++的::operator new和::operator delete做一层薄薄的包装而已。

SGI 特殊的空间配置器 std::alloc

allocator只是基层内存配置/解放行为(也就是::operator new和::operator delete)的一层薄薄包装,并没有考虑到任何效率上的强化。SGI 另有法宝供本身内部使用。

一般而言,我们所习惯的 C++ 内存配置动作和释放动作是这样:

1
2
3
class Foo { ... }; 
Foo* pf = new Foo;//配置内存,然后建构对象
delete pf; //将对象解构,然后释放内存

这其中的 new算式内含两阶段动作:

  • (1) 呼叫::operator new配置内存;

  • (2) 呼叫Foo::Foo()建构对象内容。

delete算式也内含两阶段动作:

  • (1)呼叫 Foo::~Foo()将对象解构;

  • (2)呼叫::operator delete释放内存。

为了精密分工,STL allocator决定将这两阶段动作区分开来。内存配置动作由alloc:allocate()负责,内存释放动作由alloc::deallocate()负责;对象建构动作由::construct()负责,对象解构动作由::destroy()负责。

STL标准规格告诉我们,配置器定义于之中,SGI 内含以下两个文件:

#include <stl_alloc.h> //负责内存空间的配置与释放

#include <stl_construct.h> //负责对象内容的建构与解构

内存空间的配置/释放与对象内容的建构/解构,分别着落在这两个文件身上。其中<stl_construct.h>定义有两个基本函数:建构用的 construct()和解构用的destroy()。

image-20220621205109870

建构和解构基本工具:construct() 和 destroy()

下面是<stl_construct.h> 的部份内容

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
#include <new.h> //欲使用placement new,需先含入此文件
template <class T1, class T2>
inline void construct(T1* p, const T2& value) {
new (p) T1(value); //placement new;唤起 T1::T1(value); 就是在指针p所指向的内存空间创建一个T1类型的对象,但是对象的内容是从T2类型的对象转换过来的(调用了T1的构造函数,T1::T1(value))。在已有空间的基础上重新调整分配的空间,类似于realloc函数。这个操作就是把已有的空间当成一个缓冲区来使用,这样子就减少了分配空间所耗费的时间,因为直接用new操作符分配内存的话,在堆中查找足够大的剩余空间速度是比较慢的。

}
//以下是 destroy()第一版本,接受一个指标。
template <class T>
inline void destroy(T* pointer) {
}
pointer->~T(); //唤起 dtor ~T()
//以下是 destroy()第二版本,接受两个迭代器。此函式设法找出元素的数值型别,
//进而利用 __type_traits<>求取最适当措施。
template <class ForwardIterator>
inline void destroy(ForwardIterator first, ForwardIterator last) {
__destroy(first, last, value_type(first));
}
//判断元素的数值型别(value type)是否有trivial destructor
template <class ForwardIterator, class T>
inline void __destroy(ForwardIterator first, ForwardIterator last, T*)
{
typedef typename __type_traits<T>::has_trivial_destructor trivial_destructor;
__destroy_aux(first, last, trivial_destructor());
}
//如果元素的数值型别(value type)有non-trivial destructor…
template <class ForwardIterator>
inline void __destroy_aux(ForwardIterator first, ForwardIterator last, __false_type) {
for ( ; first < last; ++first)
destroy(&*first);
}
//如果元素的数值型别(value type)有trivial destructor…
template <class ForwardIterator>
inline void __destroy_aux(ForwardIterator, ForwardIterator, __true_type) {} //什么也不做
//以下是 destroy()第二版本针对迭代器为 char*和 wchar_t*的特化版
inline void destroy(char*, char*) {}
inline void destroy(wchar_t*, wchar_t*) {}

image-20220621211422260

这两个做为建构、解构之用的函式被设计为全域函式,符合 STL 的规范。此外STL 还规定配置器必须拥有名为 construct()和destroy()的两个成员函式,然而真正在 SGI STL 中大显身手的那个名为 std::alloc 的配置器并未遵守此一规则。

上述construct()接受一个指标p和一个初值value,此函式的用途就是将初值设定到指标所指的空间上。C++ 的placement new运算子可用来完成此一 任务。

destroy()有两个版本,第一版本接受一个指标,准备将该指标所指之物解构掉。 这很简单,直接呼叫该对象的解构式即可。第二版本接受first和last两个迭代器(所谓迭代器,第三章有详细介绍),准备将[first,last)范围内的所有物件解构掉。我们不知道这个范围有多大,万一很大,而每个物件的解构式都无关痛痒(所谓 trivialdestructor),那么一次次呼叫这些无关痛痒的解构式,对效率是一种蕲伤。

因此,这里首先利用value_type()获得迭代器所指物件的型别, 再利用 __type_traits<T> 判别该型别的解构式是否无关痛痒 。若是(__true_type),什么也不做就结束;若否(__false_type),这才以循环方式巡访整个范围,并在循环中每经历一个对象就呼叫第一个版本的 destroy()。

空间的配置与释放 std::alloc

看完了内存配置后的对象建构行为,和内存释放前的对象解构行为,现在我们来看看内存的配置和释放。

对象建构前的空间配置,和对象解构后的空间释放,由<stl_alloc.h>负责,SGI 对此的设计哲学如下:

  • 向 system heap要求空间。
  • 考虑多绪(multi-threads)状态。
  • 考虑内存不足时的应变措施。
  • 考虑过多「小型区块」可能造成的内存破碎(fragment)问题。

为了将问题控制在一定的复杂度内,以下的讨论以及所摘录的源码,皆排除多绪状态的处理。

C++的记忆体配置基本动作是::operator new() ,记忆体释放基本动作是::operator delete()。这两个全域函式相当于 C 的 malloc()和 free() 函式。是的,正是如此,SGI 正是以malloc() 和free() 完成内存的配置与释放。

考虑小型区块所可能造成的内存破碎问题,SGI 设计了双层级配置器,第一级配置器直接使用 malloc()和free(),第二级配置器则视情况采用不同的策略:

  • 当配置区块超过128bytes,视之为「足够大」,便呼叫第一级配置器;

  • 当配置区块小于 128bytes,视之为「过小」,为了降低额外负担(overhead),便采用复杂的memory pool整理方式,而不再求助于第一级配置器。整个设计究竟只开放第一级配置器,或是同时开放第二级配置器,取决于__USE_MALLOC是否被定义(可以轻易测试出来,SGI STL 并未定义__USE_MALLOC):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    # ifdef __USE_MALLOC 
    ...
    typedef __malloc_alloc_template<0> malloc_alloc;
    typedef malloc_alloc alloc; //令 alloc为第一级配置器
    # else
    ...
    //令 alloc 为第二级配置器
    typedef __default_alloc_template<__NODE_ALLOCATOR_THREADS, 0> alloc;
    #endif /* ! __USE_MALLOC */

    其 中 __malloc_alloc_template就 是 第 一 级 配 置 器 , __default_alloc__template就是第二级配置器。再次提醒,alloc并不接受任何 template 型别参数。

无论alloc被定义为第一级或第二级配置器,SGI 还为它再包装一个接口如下,使配置器的接口能够符合 STL规格:

1
2
3
4
5
6
7
8
9
10
11
12
template<class T, class Alloc> 
class simple_alloc {
public:
static T *allocate(size_t n)
{ return 0 == n? 0 : (T*) Alloc::allocate(n * sizeof (T)); }
static T *allocate(void)
{ return (T*) Alloc::allocate(sizeof (T)); }
static void deallocate(T *p, size_t n)
{ if (0 != n) Alloc::deallocate(p, n * sizeof (T)); }
static void deallocate(T *p)
{ Alloc::deallocate(p, sizeof (T)); }
};

其内部四个成员函式其实都是单纯的转呼叫,呼叫传入之配置器(可能是第一级,也可能是第二级)的成员函式。这个接口使配置器的配置单位从 bytes转为个别元素的大小(sizeof(T))。SGI STL 容器全都使用这个 simple_alloc 接口,例如:

1
2
3
4
5
6
7
8
9
10
11
template <class T, class Alloc = alloc> // 预设使用 alloc为配置器
class vector {
protected:
// 专属之空间配置器,每次配置一个元素大小
typedef simple_alloc<value_type, Alloc>data_allocator;
void deallocate() {
if (...)
data_allocator::deallocate(start, end_of_storage - start);
}
...
};

一、二级配置器的关系,接口包装,及实际运用方式,可于下图略见端倪。

image-20220621214155381

image-20220622142742725

第一级配置器 __malloc_alloc_template

第一级配置器:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
#if 0 
# include <new>
# define __THROW_BAD_ALLOC throw bad_alloc
#elif !defined(__THROW_BAD_ALLOC)
# include <iostream.h>
# define __THROW_BAD_ALLOC cerr << "out of memory" << endl; exit(1)
#endif
// malloc-based allocator. 通常比稍后介绍的 default alloc 速度慢,
//一般而言是 thread-safe,并且对于空间的运用比较高效(efficient)。
//以下是第一级配置器。
//注意,无「template 型别参数」。至于「非型别参数」inst,完全没派上用场。
template <int inst>
class __malloc_alloc_template {
private:
//以下都是函式指标,所代表的函式将用来处理内存不足的情况。
// oom : out of memory.
static void *oom_malloc(size_t);
static void *oom_realloc(void *, size_t);
static void (* __malloc_alloc_oom_handler)();

public:
static void * allocate(size_t n)
{
void *result =malloc(n);//第一级配置器直接使用 malloc()
// 以下,无法满足需求时,改用 oom_malloc()
if (0 == result) result = oom_malloc(n);
return result;
}
static void deallocate(void *p, size_t /* n */)
{
free(p); //第一级配置器直接使用 free()
}
static void * reallocate(void *p, size_t /* old_sz */, size_t new_sz)
{
void * result =realloc(p, new_sz);//第一级配置器直接使用 realloc()
// 以下,无法满足需求时,改用 oom_realloc()
if (0 == result) result = oom_realloc(p, new_sz);
return result;
}

//以下模拟 C++的 set_new_handler(). 换句话说,你可以透过它,
//指定你自己的 out-of-memory handler
static void (* set_malloc_handler(void (*f)()))()
{
void (* old)() = __malloc_alloc_oom_handler;
__malloc_alloc_oom_handler = f;
return(old);
}
};
// malloc_alloc out-of-memory handling
//初值为 0。有待客端设定。
template <int inst>
void (* __malloc_alloc_template<inst>::__malloc_alloc_oom_handler)() = 0;
template <int inst>
void * __malloc_alloc_template<inst>::oom_malloc(size_t n)
{
void (* my_malloc_handler)();
void *result;
for (;;) { //不断尝试释放、配置、再释放、再配置…
my_malloc_handler = __malloc_alloc_oom_handler;
if (0 == my_malloc_handler) { __THROW_BAD_ALLOC; }
(*my_malloc_handler)();//呼叫处理例程,企图释放内存。
result = malloc(n); //再次尝试配置内存。
if (result) return(result);
}
}
template <int inst>
void * __malloc_alloc_template<inst>::oom_realloc(void *p, size_t n)
{
void (* my_malloc_handler)();
void *result;
for (;;) { //不断尝试释放、配置、再释放、再配置…
my_malloc_handler = __malloc_alloc_oom_handler;
if (0 == my_malloc_handler) { __THROW_BAD_ALLOC; }
(*my_malloc_handler)();//呼叫处理例程,企图释放内存。
result = realloc(p, n);//再次尝试配置内存。
if (result) return(result);
}
}
//注意,以下直接将参数 inst指定为 0。
typedef __malloc_alloc_template<0> malloc_alloc;

第一级配置器以malloc(), free(), realloc()等 C函式执行实际的内存配置、释放、重配置动作,并实作出类似 C++ new-handler的机制。它不能直接运用 C++ new-handler机制,因为它并非使用::operator new来配置内存。

所谓 C++ new handler 机制是,你可以要求系统在内存配置需求无法被满足时,唤起一个你所指定的函式。换句话说一旦::operator new无法达成任务,在丢出std::bad_alloc异常状态之前,会先呼叫由客端指定的处理例程。此处理例程通常即被称为 new-handler。

第二级配置器 __default_alloc_template

第二级配置器多了一些机制,避免太多小额区块造成内存的破碎。小额区块带来的其实不仅是内存破碎而已,配置时的额外负担(overhead)也是一大问题。额外负担永远无法避免,毕竟系统要靠这多出来的空间来管理内存,但是区块愈小,额外负担所占的比例就愈大、愈显得浪费。 额外负担图示如下:

image-20220622143926124

SGI第二级配置器的作法是,如果区块够大,超过 128 bytes,就移交第一级配置器处理。当区块小于 128 bytes,则以内存池(memory pool)管理,此法又称为次层配置(sub-allocation):每次配置一大块内存,并维护对应之自由串行(free-list)。下次若再有相同大小的内存需求,就直接从free-lists中拨出。如果客端释还小额区块,就由配置器回收到free-lists中—配置器除了负责配置,也负责回收。为了方便管理,SGI第二级配置器会主动将任何小额区块的内存需求量上调至8的倍数(例如客端要求 30 bytes,就自动调整为 32 bytes),并维护 16 个 free-lists,各自管理大小分别为 8, 16, 24, 32, 40, 48, 56, 64, 72, 80, 88, 96, 104, 112, 120, 128 bytes的小额区块。free-lists 的节点结构如下:

1
2
3
4
unionobj { 
union obj * free_list_link;
char client_data[1]; /* The client sees this. */
};

或许会想,为了维护串行(lists),每个节点需要额外的指标(指向下一个节点),这不又造成另一种额外负担吗?这个顾虑是对的,但早已有好的解决办法。注意,上述obj所用的是union(联合),由于union之故,从其第一字段观之,obj可被视为一个指标,指向相同形式的另一个obj。从其第二字段观之,obj可被视为一个指标,指向实际区块,如下图。

image-20220622145056139

一物二用的结果是,不会为了维护串行所必须的指针而造成内存的另一种浪费。这种技巧在强型(strongly typed)语言如 Java 中行不通,但是在非强型语言如 C++ 中十分普遍

注:Union在C++内存模型,可以理解为一块“共享内存”(不是多线(进)程概念中的共享内存)。Union开辟的大小,是其内部定义的所有元素中最大的元素。“联合”是一种特殊的类,也是一种构造类型的数据结构。在一个“联合”内可以定义多种不同的数据类型, 一个被说明为该“联合”类型的变量中,允许装入该“联合”所定义的任何一种数据,这些数据共享同一段内存,已达到节省空间的目的(还有一个节省空间的类型:位域)。 这是一个非常特殊的地方,也是联合的特征。这里所谓的共享不是指把多个成员同时装入一个联合变量内, 而是指该联合变量可被赋予任一成员值,但每次只能赋一种值, 赋入新值则冲去旧值。

第二级配置器的部份实作内容:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
enum {__ALIGN = 8};//小型区块的上调边界
enum {__MAX_BYTES = 128};//小型区块的上限
enum {__NFREELISTS = __MAX_BYTES/__ALIGN};//free-lists个数
//以下是第二级配置器。
//注意,无「template 型别参数」,且第二参数完全没派上用场。
//第一参数用于多绪环境下。本书不讨论多绪环境。
template <bool threads, int inst>
class __default_alloc_template {
private:
// ROUND_UP() 将 bytes上调至 8的倍数。
static size_t ROUND_UP(size_t bytes) {
return (((bytes) + __ALIGN-1) & ~(__ALIGN - 1));
}
private:
unionobj { //free-lists 的节点构造
union obj * free_list_link;
char client_data[1]; /* The client sees this. */
};
private:
// 16 个 free-lists
static obj * volatilefree_list[__NFREELISTS];
// 以下函式根据区块大小,决定使用第 n号 free-list。n 从 1 起算。
static size_t FREELIST_INDEX(size_t bytes) {
return (((bytes) + __ALIGN-1)/__ALIGN - 1);
}
// 传回一个大小为 n的对象,并可能加入大小为 n的其它区块到free list.
static void *refill(size_t n);
// 配置一大块空间,可容纳 nobjs 个大小为 "size" 的区块。
// 如果配置 nobjs个区块有所不便,nobjs可能会降低。
static char *chunk_alloc(size_t size, int &nobjs);
// Chunk allocation state.
static char *start_free;//记忆池起始位置。只在 chunk_alloc()中变化
static char *end_free;//记忆池结束位置。只在 chunk_alloc()中变化
static size_t heap_size;
public:
static void *allocate(size_t n) { /* 详述于后 */ }
static void deallocate(void *p, size_t n) { /* 详述于后 */ }
static void *reallocate(void *p, size_t old_sz, size_t new_sz);
};
//以下是 static data member 的定义与初值设定
template <bool threads, int inst>
char *__default_alloc_template<threads, inst>::start_free = 0;
template <bool threads, int inst>
char *__default_alloc_template<threads, inst>::end_free = 0;
template <bool threads, int inst>
size_t__default_alloc_template<threads, inst>::heap_size = 0;
template <bool threads, int inst>
__default_alloc_template<threads, inst>::obj *volatile
__default_alloc_template<threads, inst>::free_list[__NFREELISTS] =
{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, };

空间配置函式 allocate()

身为一个配置器,__default_alloc_template 拥有配置器的标准介面函式allocate()。此函式首先判断区块大小,大于 128 bytes 就呼叫第一级配置器,小于 128 bytes 就检查对应的 free list。如果free list之内有可用的区块,就直接拿来用,如果没有可用区块,就将区块大小上调至 8 倍数边界,然后呼叫refill(),准备为 free list 重新填充空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// n must be > 0 
static void *allocate(size_t n)
{
obj * volatile * my_free_list;
obj * result;
// 大于 128 就呼叫第一级配置器
if (n > (size_t) __MAX_BYTES)
return(malloc_alloc::allocate(n));
}
// 寻找 16 个 free lists 中适当的一个
my_free_list = free_list + FREELIST_INDEX(n);
result = *my_free_list;
if (result == 0) {
// 没找到可用的 free list,准备重新填充 free list
void *r = refill(ROUND_UP(n));
return r;
}
// 调整 free list
//下节详述
*my_free_list = result -> free_list_link;
return (result);
};

区块自free list拨出的操作如下图

image-20220622150916002

空间释还函式 deallocate()

身为一个配置器,__default_alloc_template 拥有配置器标准介面函式deallocate()。此函式首先判断区块大小,大于 128 bytes 就呼叫第一级配置器,小于 128 bytes 就找出对应的 free list,将区块回收。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// p 不可以是 0 
static void deallocate(void *p, size_t n)
{
obj *q = (obj *)p;
obj * volatile * my_free_list;
// 大于 128 就呼叫第一级配置器
if (n > (size_t) __MAX_BYTES) {
malloc_alloc::deallocate(p, n);
return;
}
// 寻找对应的 free list
my_free_list = free_list + FREELIST_INDEX(n);
// 调整 free list,回收区块
q -> free_list_link = *my_free_list;
*my_free_list = q;
}

区块回收纳入free list的动作,如下图

image-20220622151423842

重新充填 free lists

讨论先前说过的 allocate()。当它发现free list中没有可用区块了,就呼叫refill() 准备为free list重新填充空间。新的空间将取自内存池(经由chunk_alloc()完成)。预设取得20个新节点(新区块),但万一内存池空间不足,获得的节点数(区块数)可能小于 20:

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
//传回一个大小为 n的对象,并且有时候会为适当的freelist增加节点. 
//假设 n已经适当上调至 8的倍数。
template <bool threads, int inst>
void* __default_alloc_template<threads, inst>::refill(size_t n)
{
int nobjs = 20;
// 呼叫 chunk_alloc(),尝试取得 nobjs个区块做为 free list的新节点。
// 注意参数 nobjs是pass by reference。
char * chunk =chunk_alloc(n, nobjs); //下节详述
obj * volatile * my_free_list;
obj * result;
obj * current_obj, * next_obj;
int i;
// 如果只获得一个区块,这个区块就拨给呼叫者用,free list无新节点。
if (1 == nobjs) return(chunk);
// 否则准备调整 free list,纳入新节点。
my_free_list = free_list + FREELIST_INDEX(n);
// 以下在 chunk空间内建立freelist
result = (obj *)chunk; //这一块准备传回给客端
// 以下导引 free list指向新配置的空间(取自内存池)
*my_free_list = next_obj = (obj *)(chunk + n);
// 以下将 free list 的各节点串接起来。
for (i = 1; ; i++) {//从 1 开始,因为第 0 个将传回给客端
current_obj = next_obj;
next_obj = (obj *)((char *)next_obj + n);
if (nobjs - 1 == i) {
current_obj -> free_list_link = 0;
break;
} else {
current_obj -> free_list_link = next_obj;
}
}
return(result);
}

内存池(memory pool)

从内存池中取空间给free list使用,是 chunk_alloc()的工作:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
//假设 size 已经适当上调至 8的倍数。
//注意参数 nobjs是pass by reference。
template <bool threads, int inst>
char*
__default_alloc_template<threads, inst>::
chunk_alloc(size_t size, int& nobjs)
{
char * result;
size_t total_bytes = size * nobjs;
size_t bytes_left = end_free - start_free;// 内存池剩余空间
if (bytes_left >= total_bytes) {
// 内存池剩余空间完全满足需求量。
result = start_free;
start_free += total_bytes;
return(result);
} else if (bytes_left >= size) {
// 内存池剩余空间不能完全满足需求量,但足够供应一个(含)以上的区块。
nobjs = bytes_left/size;
total_bytes = size * nobjs;
result = start_free;
start_free += total_bytes;
return(result);
} else {
// 内存池剩余空间连一个区块的大小都无法提供。
size_t bytes_to_get = 2 * total_bytes + ROUND_UP(heap_size >> 4);
// 以下试着让内存池中的残余零头还有利用价值。
if (bytes_left > 0) {
// 内存池内还有一些零头,先配给适当的 free list,因为其他free list区块可能更小
// 首先寻找适当的 free list。
obj * volatile * my_free_list =
free_list + FREELIST_INDEX(bytes_left);
// 调整 free list,将内存池中的残余空间编入。
((obj *)start_free) -> free_list_link = *my_free_list;
*my_free_list = (obj *)start_free;
}
// 配置 heap 空间
start_free = (char *)malloc(bytes_to_get);
if (0 == start_free) {
// heap 空间不足,malloc() 失败。
int i;
obj * volatile * my_free_list, *p;
// 试着检视我们手上拥有的东西。这不会造成伤害。我们不打算尝试配置
// 较小的区块,因为那在多行程(multi-process)机器上容易导致灾难
// 以下搜寻适当的 free list,
// 所谓适当是指「尚有未用区块,且区块够大」之 free list。
for (i = size; i <= __MAX_BYTES; i += __ALIGN) {
my_free_list = free_list + FREELIST_INDEX(i);
p = *my_free_list;
if (0 != p) {//free list 内尚有未用区块。
// 调整 free list以释出未用区块
*my_free_list = p -> free_list_link;
start_free = (char *)p;
end_free = start_free + i;
// 递归呼叫自己,为了修正 nobjs。
return(chunk_alloc(size, nobjs));
// 注意,任何残余零头终将被编入适当的 free-list中备用。
}
}
end_free = 0; // 如果出现意外(山穷水尽,到处都没内存可用了)
// 呼叫第一级配置器,看看out-of-memory机制能否尽点力
start_free = (char *)malloc_alloc::allocate(bytes_to_get);
// 这会导致掷出异常(exception),或内存不足的情况获得改善。
}
heap_size += bytes_to_get;
end_free = start_free + bytes_to_get;
// 递归呼叫自己,为了修正 nobjs。
return(chunk_alloc(size, nobjs));
}
}

上述的 chunk_alloc()函式以end_free - start_free 来判断内存池的“水量”。如果水量充足,就直接拨出 20 个区块传回给 free list。如果水量不足以提供 20 个区块,但还足够供应一个以上的区块,就拨出这不足20个区块的空间出去。这时候其pass by reference 的 nobjs 参数将被修改为实际能够供应的区块数。如果内存池连一个区块空间都无法供应,对客端显然无法交待,此时便需利用malloc() 从 heap 中配置内存,为内存池注入活水源头以应付需求。新水量的大小为需求量的两倍,再加上一个随着配置次数增加而愈来愈大的附加量。

一个例子见下图,假设程序一开始,客端就呼叫chunk_alloc(32,20),于是malloc()配置 40个 32bytes区块,其中第 1 个交出,另 19 个交给free_list[3] 维护,余20个留给内存池。接下来客端呼叫chunk_alloc(64,20),此时free_list[7] 空空如也,必须向内存池要求支持。内存池只够供应 (32*20)/64=10 个 64bytes区块,就把这 10 个区块传回,第 1 个交给客端,余 9个由 free_list[7] 维护。此时内存池全空。接下来再呼叫chunk_alloc(96, 20),此时 free_list[11] 空空如也,必须向内存池要求支持,而内存池此时也是空的,于是以malloc()配 置 40+n(附加量)个 96bytes 区块,其中第 1 个交出,另 19 个交给 free_list[11] 维护,余 20+n(附加量)个区块留给内存池……。

image-20220622152750430

万一整个system heap 空间都不够了(以至无法为内存池注入活水源头),malloc()行动失败,chunk_alloc()就四处寻找有无「尚有未用区块,且区块够大」之free lists。找到的话就挖一块交出,找不到的话就呼叫第一级配置器。第一级配置器其实也是使用malloc()来配置内存,但它有 out-of-memory 处理机制(类似 new-handler 机制),或许有机会释放其它的内存拿来此处使用。如果可以,就成功,否则发出bad_alloc异常

内存基本处理工具

STL定义有五个全域函式,作用于未初始化空间上。这样的功能对于容器的实作很有帮助。

前两个函式是用于建构的construct()和用于解构的destroy(),另三个函式是uninitialized_copy()、uninitialized_fill()、uninitialized_fill_n(),分别对应于高阶函式copy()、fill()、fill_n()——这些都是 STL 算法。如果要使用本节的三个低阶函式,应该含入,不过SGI 把它们实际定义于

uninitialized_copy(复制迭代器)

1
2
3
template <class InputIterator, class ForwardIterator> 
ForwardIterator
uninitialized_copy(InputIterator first, InputIterator last, ForwardIterator result);

注:

1
2
3
4
5
6
7
8
9
InputIterator:输入迭代器。支持对容器元素的逐个遍历,以及对元素的读取(input);
OutputIterator:输出迭代器。支持对容器元素的逐个遍历,以及对元素的写入(output)。
ForwardIterator:前向迭代器。向前逐个遍历元素。可以对元素读取;
BidirectionalIterator:双向迭代器。支持向前向后逐个遍历元素,可以对元素读取。
RandomAccessIterator:随机访问迭代器。支持O(1)时间复杂度对元素的随机位置访问,支持对元素的读取。

输出迭代器可以修改元素,这可能会导致内部结构的调整,进而导致原有的迭代器失效!可能的情况有:
结构和元素顺序变更:比如对map,set,priority_queue插入元素;
内存变化:比如对vector插入元素,可能导致重新申请内存并拷贝

uninitialized_copy() 使我们能够将内存的配置与对象的建构行为分离开来。如果做为输出目的地的 [result, result+(last-first)) 范围内的每一个迭代器都指向未初始化区域,则 uninitialized_copy() 会使用copy constructor,为身为输入来源之 [first,last) 范围内的每一个对象产生一份复制品,放进输出范围中。换句话说,针对输入范围内的每一个迭代器 i,此函式会呼叫construct(&*(result+(i-first)),*i),产生*i的复制品,放置于输出范围的相对位置上。

如果需要实作一个容器,uninitialized_copy()这样的函式会带来很大的帮助,因为容器的全范围建构式(range constructor)通常以两个步骤完成:

  • 配置内存区块,足以包含范围内的所有元素。

  • 使用uninitialized_copy(),在该内存区块上建构元素。

C++标准规格书要求uninitialized_copy()具有 “commit or rollback“语意,意思是要不就「建构出所有必要元素」,要不就(当有任何一个copy constructor 失败时)「不建构任何东西」

uninitialized_fill (填充给定对象的值)

1
2
template <class ForwardIterator, class T> 
void uninitialized_fill(ForwardIterator first, ForwardIterator last, const T& x);

uninitialized_fill() 也能够使我们将内存配置与对象的建构行为分离开来。如果[first,last)范围内的每个迭代器都指向未初始化的内存,那么

uninitialized_fill()会在该范围内产生x(上式第三参数)的复制品。换句话说uninitialized_fill()会针对操作范围内的每个迭代器 i ,呼叫construct(&*i, x),在i所指之处产生x的复制品。

和 uninitialized_copy()一样,uninitialized_fill() 必须具备 “commit or rollback“语意,换句话说它要不就产生出所有必要元素,要不就不产生任何元素。如果有任何一个copy constructor丢出异常(exception),uninitialized_fill() 必须能够将已产生之所有元素解构掉。

uninitialized_fill_n (fill的不同参数写法)

1
2
3
template <class ForwardIterator, class Size, class T> 
ForwardIterator
uninitialized_fill_n(ForwardIterator first, Size n, const T& x);

uninitialized_fill_n()能够使我们将内存配置与对象建构行为分离开来。它会为指定范围内的所有元素设定相同的初值。

如果[first, first+n)范围内的每一个迭代器都指向未初始化的内存,那么uninitialized_fill_n()会呼叫copy constructor,在该范围内产生x(上式第三参数)的复制品。也就是说面对 [first,first+n)范围内的每个迭代器 i,uninitialized_fill_n()会呼叫construct(&*i, x),在对应位置处产生x 的复制品。

uninitialized_fill_n()也具有 “commit or rollback“语意:要不就产生所有必要的元素,否则就不产生任何元素。如果任何一个copy constructor丢出异常(exception),uninitialized_fill_n() 必须解构已产生的所有元素。

具体实现

uninitialized_fill_n

本函式接受三个参数:

  • 迭代器first指向欲初始化空间的起始处

  • n表示欲初始化空间的大小

  • x表示初值

1
2
3
4
5
template <class ForwardIterator, class Size, class T> 
inline ForwardIterator uninitialized_fill_n(ForwardIteratorfirst, Size n, const T&x) {
return __uninitialized_fill_n(first, n, x, value_type(first));
// 以上,利用 value_type() 取出 first的 value type.
}

这个函式的进行逻辑是,首先萃取出迭代器 first 的 value type(详见下节),然后判断该型别是否为 POD型别:

1
2
3
4
5
6
7
template <class ForwardIterator, class Size, class T, class T1> 
inline ForwardIterator __uninitialized_fill_n(ForwardIterator first, Size n, const T& x, T1*)
{
// 以下 __type_traits<> 技法,详见下节
typedef typename __type_traits<T1>::is_POD_typeis_POD;
return __uninitialized_fill_n_aux(first, n, x, is_POD());
}

POD意指 Plain Old Data,也就是纯量型别(scalar types)或传统的 C struct型别。POD型别必然拥有 trivialctor/dtor/copy/assignment函式,因此,可以对POD型别采取最有效率的初值填写手法,而对non-POD 型别采取最保险安全的作法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//如果 copy construction 等同于 assignment, 而且
// destructor 是 trivial,以下就有效。
//如果是 POD型别,执行流程就会转进到以下函式。这是藉由 function template
//的自变量推导机制而得。
template <class ForwardIterator, class Size, class T>
inline ForwardIterator
__uninitialized_fill_n_aux(ForwardIterator first, Size n, const T& x, __true_type) {
return fill_n(first, n, x);//交由高阶函式执行。见第6节。//注:实际上就是走访容器,执行赋值构造函数(因为pod型必有assignment构造函数)
}
// 如果不是 POD 型别,执行流程就会转进到以下函式。这是藉由 function template
//的自变量推导机制而得。
template <class ForwardIterator, class Size, class T>
ForwardIterator
__uninitialized_fill_n_aux(ForwardIterator first, Size n, const T& x, __false_type) {
ForwardIterator cur = first;
// 为求阅读顺畅,以下将原本该有的异常处理(exception handling)省略。
for ( ; n > 0; --n, ++cur)
construct(&*cur, x); //注:调用placement new
return cur;
}

uninitialized_copy

本函式接受三个参数:

  • 迭代器first指向输入端的起始位置
  • 迭代器last指向输入端的结束位置(前闭后开区间)
  • 迭代器result指向输出端(欲初始化空间)的起始处
1
2
3
4
5
6
template <class InputIterator, class ForwardIterator> 
inline ForwardIterator
uninitialized_copy(InputIterator first, InputIterator last, ForwardIterator result) {
return __uninitialized_copy(first, last, result,value_type(result));
// 以上,利用 value_type() 取出 first的 value type.
}

这个函式的进行逻辑是,首先萃取出迭代器 result 的 value type,然后判断该型别是否为 POD型别:

1
2
3
4
5
6
7
8
template <class InputIterator, class ForwardIterator, class T> 
inline ForwardIterator
__uninitialized_copy(InputIterator first, InputIterator last,
ForwardIterator result, T*) {
typedef typename __type_traits<T>::is_POD_type is_POD;
return __uninitialized_copy_aux(first, last, result,is_POD());
// 以上,企图利用 is_POD() 所获得的结果,让编译器做自变量推导。
}
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
//如果 copy construction 等同于 assignment, 而且
// destructor 是 trivial,以下就有效。
//如果是 POD型别,执行流程就会转进到以下函式。这是藉由 function template
//的自变量推导机制而得。
template <class InputIterator, class ForwardIterator>
inline ForwardIterator
__uninitialized_copy_aux(InputIterator first, InputIterator last,
ForwardIterator result,
__true_type) {
return copy(first, last, result);//呼叫 STL算法 copy()
}
// 如果是 non-POD型别,执行流程就会转进到以下函式。这是藉由 function template
//的自变量推导机制而得。
template <class InputIterator, class ForwardIterator>
ForwardIterator
__uninitialized_copy_aux(InputIterator first, InputIterator last,
ForwardIterator result,
__false_type) {
ForwardIterator cur = result;
// 为求阅读顺畅,以下将原本该有的异常处理(exception handling)省略。
for ( ; first != last; ++first, ++cur)
construct(&*cur, *first);//必须一个一个元素地建构,无法批量进行
return cur;
}
}

针对char和wchar_t两种型别,可以最具效率的作法memmove(直接搬移内存内容)来执行复制行为。因此 SGI 得以为这两种型别设计一份特化版本。

1
2
3
4
5
6
7
8
9
10
11
12
//以下是针对 const char*的特化版本
inline char*uninitialized_copy(const char* first, const char* last,
char* result) {
memmove(result, first, last - first);
return result + (last - first);
}
//以下是针对 const wchar_t* 的特化版本
inline wchar_t* uninitialized_copy(const wchar_t* first, const wchar_t* last,
wchar_t* result) {
memmove(result, first, sizeof(wchar_t) * (last - first));
return result + (last - first);
}

uninitialized_fill

本函式接受三个参数:

  • 迭代器first指向输出端(欲初始化空间)的起始处
  • 迭代器last指向输出端(欲初始化空间)的结束处(前闭后开区间)
  • x表示初值
1
2
3
4
template <class ForwardIterator, class T> 
inline void uninitialized_fill(ForwardIterator first, ForwardIterator last, const T& x) {
__uninitialized_fill(first, last, x, value_type(first));
}

这个函式的进行逻辑是,首先萃取出迭代器 first 的 value type,然后判断该型别是否为 POD型别:

1
2
3
4
5
6
template <class ForwardIterator, class T, class T1> 
inline void __uninitialized_fill(ForwardIterator first, ForwardIterator last,
const T& x, T1*) {
typedef typename __type_traits<T1>::is_POD_type is_POD;
__uninitialized_fill_aux(first, last, x, is_POD());
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//如果 copy construction 等同于 assignment, 而且
// destructor 是 trivial,以下就有效。
//如果是 POD型别,执行流程就会转进到以下函式。这是藉由 function template
//的自变量推导机制而得。
template <class ForwardIterator, class T>
inline void
__uninitialized_fill_aux(ForwardIterator first, ForwardIterator last,
const T& x, __true_type)
{
fill(first, last, x);//呼叫 STL算法 fill()
}
// 如果是 non-POD型别,执行流程就会转进到以下函式。这是藉由 function template
//的自变量推导机制而得。
template <class ForwardIterator, class T>
void
__uninitialized_fill_aux(ForwardIterator first, ForwardIterator last,
const T& x, __false_type)
{
ForwardIterator cur = first;
// 为求阅读顺畅,以下将原本该有的异常处理(exception handling)省略。
for ( ; cur != last; ++cur)
construct(&*cur, x);//必须一个一个元素地建构,无法批量进行
}
}

下图是三个内存基本函数的泛型版本和特化版本图示

image-20220622225921035

总结

空间配置器是最底层的操作之一,STL把传统的new(new operator)分成两步来做(这是核心),也即operator new(配置内存)和placement new(建构元素)。其中建构元素的操作对应使用construct()和destroy()函数,配置内存的操作使用allocate()和deallocate()函数。(分开来可以提高效率,比如重新建构元素就不用直接从分配空间开始,用原来的空间即可)

配置内存分为一级配置和二级配置,根据__USE_MALLOC 是否定义区分使用,如果为true则为第一级配置器。第一级配置器直接使用malloc(), free(), realloc()等 C函式执行实际的内存动作(包括自定义handler)。第二级配置器的作法是,如果区块够大,超过 128 bytes,就移交第一级配置器处理。当区块小于 128 bytes,则以内存池(memory pool)管理。首先第二级配置器有一个串行,有16个节点,每个节点有许多内存区块,大小为8*(n+1),其中n是节点编号。每次调用allocate()就找合适的区块,deallocate()把区块空间归还即可。而内存池负责给予串行内存区块,涉及内存不够时的一些其他操作(比如从其他区块拿,或者从堆拿空间)。

在泛型的同时判别一些特化版本,进一步提高效率。

迭代器(iterators)概念 与traits 编程技法

迭代器(iterators)是一种抽象的设计概念,现实程序语言中并没有直接对映于这个概念的实物。《Design Patterns》一书提供有 23 个设计样式(design patterns)的完整描述,其中 iterator 样式定义如下:提供一种方法,俾得依序巡访某个聚合物(容器)所含的各个元素,而又无需曝露该聚合物的内部表述方式。

迭代器设计思维STL 关键所在

STL 的中心思想在于,将数据容器(containers)和算法(algorithms)分开,彼此独立设计,最后再以一帖胶着剂将它们撮合在一起。容器和算法的泛型化,从技术角度来看并不困难,C++ 的 class templates 和 function templates可分别达成目标。如何设计出两者之间的良好胶着剂,才是大难题。

以下是容器、算法、迭代器(iterator,扮演黏胶角色)的合作展示。以算法 find() 为例,它接受两个迭代器和一个「搜寻标的」:

1
2
3
4
5
6
7
8
9
//摘自 SGI <stl_algo.h> 
template <class InputIterator, class T>
InputIterator find(InputIterator first,
InputIterator last,
const T& value) {
while (first != last && *first != value)
++first;
return first;
}

只要给予不同的迭代器,find()便能够对不同的容器做搜寻动作。

迭代器(iterator)是一种 smart pointer

迭代器是一种行为类似指针的对象,而指针的各种行为中最常见也最重要的便是内容提领(dereference成员取用(member access),因此迭代器最重要的编程工作就是对 operator* 和 operator-> 进行多载化(overloading)工程。

C++ 标准链接库有一个auto_ptr可供我们参考。任何一本详尽的 C++ 语法书籍都应该谈到auto_ptr,这是一个用来包装原生指标(native pointer)的对象,声名狼藉的内存漏洞(memory leak)问题可藉此获得解决。auto_ptr用法如下,和原生指标一模一样:

1
2
3
4
5
6
7
8
9
void func() 
{
auto_ptr<string> ps(new string("jjhou"));
cout << *ps << endl;
cout << ps->size() << endl;
//输出:jjhou
//输出:5
// 离开前不需 delete, auto_ptr 会自动释放内存
}

函式第一行的意思是,以算式new 动态配置一个初值为”jjhou”的string物件,并将所得结果(一个原生指针)做为auto_ptr<string>对象的初值。注意,auto_ptr 角括号内放的是「原生指针所指对象」的型别,而不是原生指标的型别。

auto_ptr的源码在头文件中,这里就不给出了。

现在来为list(串行)设计一个迭代器。假设 list 及其节点的结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// file: 3mylist.h 
template <typename T>
class List //容器
{
void insert_front(T value);
void insert_end(T value);
void display(std::ostream &os = std::cout) const;
// ...
private:
ListItem<T>* _end;
ListItem<T>* _front;
long _size;
};
template <typename T>
class ListItem //节点结构,值和指针
{
public:
T value() const { return _value; }
ListItem* next() const { return _next; }
...
private:
T _value;
ListItem* _next; // 单向串行(single linked list)
};

要将这个List套用到先前所说的find(),需要为它设计一个行为类似指标的外衣,也就是一个迭代器。当我们提领(dereference)此一迭代器(用***),传回的应该是个ListItem 对象;当我们累加该迭代器(用++**),它应该指向下一个ListItem 物件。为了让此迭代器适用于任何型态的节点,而不只限于ListItem,我们可以将它设计为一个 class template:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// file : 3mylist-iter.h 
#include "3mylist.h"
template <class Item> // Item 可以是单向串行节点或双向串行节点。
struct ListIter //此处这个迭代器特定只为串行服务,因为其
{ //独特的 operator++ 之故。
Item* ptr;//保持与容器之间的一个联系(keep a reference to Container)
ListIter(Item* p = 0) // default ctor
: ptr(p) { }
// 不必实作 copy ctor,因为编译器提供的预设行为已足够。
// 不必实作 operator=,因为编译器提供的预设行为已足够。
Item&operator*() const { return *ptr; } //注:返回一个左值,可以赋值,要用引用
Item*operator->() const { return ptr; } //注:返回一个指针
// 以下两个 operator++ 遵循标准作法,参见[Meyers96]条款 6
// (1) pre-increment operator
ListIter&operator++()
{ ptr = ptr->next(); return *this; }
// (2) post-increment operator
ListIter operator++(int)//注:tmp是临时变量,因此函数返回类型不能用引用,也不是左值
{ ListIter tmp = *this; ++*this; return tmp; }
bool operator==(const ListIter& i) const
{ return ptr == i.ptr; }
bool operator!=(const ListIter& i) const
{ return ptr != i.ptr; }
};

现在我们可以这样子将 List和find()藉由ListIter 黏合起来(使自己设计的List能通过ListIter来使用find()):

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
// 3mylist-iter-test.cpp 
void main()
{
List<int> mylist;
for(int i=0; i<5; ++i) {
mylist.insert_front(i);
mylist.insert_end(i+2);
}
mylist.display(); // 10 ( 4 3 2 1 0 2 3 4 5 6 )
ListIter<ListItem<int> >begin(mylist.front());
ListIter<ListItem<int> >end; // default 0, null
ListIter<ListItem<int> > iter; // default 0, null
iter = find(begin, end, 3);
if (iter == end)
cout << "not found" << endl;
else
cout << "found. " << iter->value() << endl;
// 执行结果:found. 3
iter = find(begin, end, 7);
if (iter == end)
cout << "not found" << endl;
else
cout << "found. " << iter->value() << endl;
// 执行结果:not found
}

注意,由于find()函式内以*iter != value来检查元素值是否吻合,而本例之中value的型别是int,iter的型别是ListItem<int>,两者之间并无可供使用的operator!=,所以我必须另外写一个全域的operator!=多载函式,并以int和ListItem<int>做为它的两个参数型别:

1
2
3
template <typename T> 
bool operator!=(const ListItem<T>& item,T n)
{ return item.value() != n; }

从以上实作可以看出,为了完成一个针对 List 而设计的迭代器,我们无可避免地 曝露了太多List实作细节:在main()之中为了制作begin和end两个迭代器,我们曝露了ListItem;在ListIter class之中为了达成operator++的目的,我们曝露了 ListItem 的操作函式next()。

如果不是为了迭代器,ListItem 原本应该完全隐藏起来不曝光的。换句话说,要设计出 ListIter,首先必须对 List 的实作细节有非常丰富的了解。既然这无可避免,干脆就把迭代器的开发工作交给List的设计者好了,如此一来所有实作细节反而得以封装起来不被使用者看到。这正是为什么每一种 STL容器都提供有专属迭代器的缘故。

迭代器相应型别(associated types)

上述的ListIter提供了一个迭代器雏形。如果将思想拉得更高远一些,我们便会发现,算法之中运用迭代器时,很可能会用到其相应型别(associated type)。什么是相应型别?迭代器所指之物的型别便是其一。

假设算法中有必要宣告一 个变量,以「迭代器所指对象的型别」为型别,如何是好?毕竟C++只支援**sizeof(),并未支持typeof()**。

解决办法是:利用 function template (函数模板)的自变量推导(argument deducation)机制,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template <class I, class T> 
void func_impl(I iter, T t) //多了类型T,T是迭代器指向对象的类型
{
T tmp; // 这里解决了问题。T就是迭代器所指之物的型别,本例为 int
// ... 这里做原本 func()应该做的全部工作
};
template <class I>
inline
void func(I iter)
{
func_impl(iter,*iter);// func 的工作全部移往 func_impl
}
int main()
{
int i;
func(&i);
}

我们以func()为对外界面,却**把实际动作全部置于func_impl() **之中。由于func_impl()是一个 function template,一旦被呼叫,编译器会自动进行 template 自变量推导。于是导出型别T,顺利解决了问题。

迭代器相应型别(associated types)不只是「迭代器所指对象的型别」一种而已。根据经验,最常用的相应型别有五种,然而并非任何情况下任何一种都可利用上述的 template自变量推导机制来取得。我们需要更全面的解法。

Traits 编程技法——STL 源码门钥

迭代器所指物件的型别,称为该迭代器的value type。上述的自变量型别推导技巧虽然可用于 value type,却非全面可用:万一value type必须用于函式的传回值,就束手无策了,毕竟函式的「template 自变量推导机制」推而导之的只是自变量,无法推导函式的回返值型别。

声明内嵌型别似乎是个好主意,像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <class T> 
struct MyIter
typedef T value_type; // 内嵌型别声明(nested type)
T* ptr;
MyIter(T* p=0) : ptr(p) { }
T& operator*() const { return *ptr; }
// ...
};
template <class I> //注:I指上面的MyIter
typename I::value_type //这一整行是 func的回返值型别
func(I ite)
{ return *ite; }
// ...
MyIter<int> ite(new int(8));
cout << func(ite); //输出:8

注意,func()的回返型别必须加上关键词 typename,因为T是一个 template参数,在它被编译器具现化之前,编译器对T一无所悉,换句话说编译器此时并不知道MyIter<T>::value_type代表的是一个型别或是一个 member function或是一个 data member。关键词typename的用意在告诉编译器说这是一个型别,如此才能顺利通过编译。

看起来不错。但是有个隐晦的陷阱:并不是所有迭代器都是 class type。原生指标就不是。如果不是 class type,就无法为它定义内嵌型别。但 STL(以及整个泛型思维)绝对必须接受原生指标做为一种迭代器,所以上面这样还不够。更好的方式是使用template partial specialization(模板偏特化)。

Partial Specialization(偏特化)的意义:如果 class template拥有一个以上的 template 参数,我们可以针对其中某个(或数个,但非全部)template参数进行特化工作。换句话说我们可以在泛化设计中提供一个特化版本(也就是将泛化版本中的某些template参数赋予明确的指定)。

假设有一个 class template如下:

1
2
template<typename U, typename V, typename T> 
class C { ... };

partial specialization的字面意义容易误导我们以为,所谓「偏特化版」一定是对template参数 U 或 V 或 T(或某种组合)指定某个自变量值。事实不然,「所谓partial specialization的意思是提供另一份 template定义式,而其本身仍为 templatized」。《泛型技术》 一书对 partial specialization 的定义是:「针对(任何)template 参数更进一步的条件限制,所设计出来的一个特化版本」。

由此,面对以下这么一个 class template:

1
2
template<typename T> 
class C { ... }; // 这个泛化版本允许(接受)T为任何型别

我们便很容易接受它有一个型式如下的partial specialization:

1
2
3
template<typename T> 
class C<T*> { ... }; //这个特化版本仅适用于「T为原生指针」的情况
// 「T为原生指针」便是「T 为任何型别」的一个更进一步的条件限制

注:原生指针即 (类型名*p)样子的指针,类型名可以是基础类型,如int,double等,也可以是一个自己定义的Class类。相反的如果一个类重载了‘*’和‘**->*’的运算符,可以像指针一样用‘’和‘->’操作,就不是原生的,如iterator等。

有了这项利器,我们便可以解决前述「内嵌型别」未能解决的问题。先前的问题是,原生指针并非 class,因此无法为它们定义内嵌型别。现在,我们可以针对「迭代器之 template自变量为指标」者,设计特化版的迭代器。

下面这个 class template专门用来「萃取」迭代器的特性,而 value type 正是迭代器的特性之一:

1
2
3
4
template <class I> 
struct iterator_traits { // traits 意为「特性」
typedef typename I::value_type value_type;
};

这个所谓的traits,其意义是,如果I定义有自己的value type,那么透过这个traits的作用,萃取出来的value_type就是I::value_type。换句话说如果I 定义有自己的value type,先前那个func()可以改写成这样:

1
2
3
4
template <class I> 
typename iterator_traits<I>::value_type // 这一整行是函式回返型别,注:此时类I内定义了value_type
func(I ite)
{ return *ite; }

这样做的好处是traits可以拥有特化版本。现在,我们令 iterator_traites拥有一个partial specializations 如下:

1
2
3
4
template <class T> 
struct iterator_traits<T*> { //偏特化版—迭代器是个原生指标,注:此时T(比如int)也许没有定义value_type,则可以根据T* 获取T,也就得到了type
typedef T value_type;
};

于是,原生指标int* 虽然不是一种 class type,亦可透过traits取其value type。这就解决了先前的问题。

但是请注意,针对「指向常数对象的指针(pointer-to-const)」,下面这个式子得到什么结果:

1
iterator_traits<const int*>::value_type

获得的是const int而非int。这不是所期望的。我们希望利用这种机制来宣告一个暂时变量,使其型别与迭代器的value type相同,而现在,宣告一个无法赋值(因const之 故 )的暂时变数,没什么用!因此,如果迭代器是个pointer-to-const,我们应该设法令其value type为一个 non-const型别。只要另外设计一个特化版本,就能解决这个问题:

1
2
3
4
template <class T> 
struct iterator_traits<const T*> { // 偏特化版—当迭代器是个pointer-to-const ,注:这个版本多了const,针对const的实例会进入这个版本,萃取T
typedef T value_type; // 萃取出来的型别应该是 T 而非 const T
};

现在,不论面对的是迭代器MyIter,或是原生指标int或const int,都可以透过traits取出正确的(我们所期望的)value type

下图说明traits所扮演的「特性萃取机」角色,萃取各个迭代器的特性。这里所谓的迭代器特性,指的是迭代器的相应型别(associated types)。当然,若要这个「特性萃取机」traits能够有效运作,每一个迭代器必须遵循约定,自行以内嵌型别定义(nested typedef)的方式定义出相应型别(associated types)。这种一个约定,谁不遵守这个约定,谁就不能相容于 STL 这个大家庭。

image-20220623154430152

根据经验,最常用到的迭代器相应型别有五种:

  • value type
  • difference type
  • pointer
  • reference
  • iterator catagory

如果你希望你所开发的容器能与 STL 水乳交融,一 定要为你的容器的迭代器定义这五种相应型别。「特性萃取机」traits 会很忠实地

将原汁原味榨取出来:

1
2
3
4
5
6
7
8
template <class I> 
struct iterator_traits {
typedef typename I::iterator_category iterator_category;
typedef typename I::value_type value_type;
typedef typename I::difference_type difference_type;
typedef typename I::pointer pointer;
typedef typename I::reference reference;
};

iterator_traits必须针对传入之型别为 pointer 及 pointer-to-const者,设计特化版本,见下节。

迭代器相应型别之一:value type

所谓value type,是指迭代器所指对象的型别。任何一个打算与 STL算法有完美搭配的 class,都应该定义自己的 value type 内嵌型别,作法就像上节所述。

迭代器相应型别之二:difference type

difference type 用来表示两个迭代器之间的距离,也因此,它可以用来表示一个容器的最大容量,因为对于连续空间的容器而言,头尾之间的距离就是其最大容量。

如果一个泛型算法提供计数功能,例如 STL的count(),其传回值就必须使用迭代器的 difference type

1
2
3
4
5
6
7
8
9
template <class I, class T> 
typename iterator_traits<I>::difference_type //这一整行是函式回返型别
count(I first, I last, const T& value) {
typename iterator_traits<I>::difference_type n = 0;
for ( ; first != last; ++first)
if (*first == value)
++n;
return n;
}

针对相应型别difference typetraits的两个(针对原生指标而写的)特化版本如下,以C++内建的ptrdiff_t(定义于头文件)做为原生指标的difference type

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template <class I> 
struct iterator_traits {
...
typedef typename I::difference_type difference_type;
};
//针对原生指标而设计的「偏特化(partial specialization)」版
template <class T>
struct iterator_traits<T*> {
...
typedef ptrdiff_t difference_type;
};
//针对原生的 pointer-to-const 而设计的「偏特化(partial specialization)」版
template <class T>
struct iterator_traits<const T*> {
...
typedef ptrdiff_t difference_type;
};

现在,任何时候当我们需要任何迭代器 I的difference type,可以这么写:

1
typename iterator_traits<I>::difference_type

迭代器相应型别之三:reference type

「迭代器所指之物的内容是否允许改变」的角度观之,迭代器分为两种:不允许改变「所指对象之内容」者,称为constant iterators,例如const int* pic;允许改变「所指对象之内容」者,称为 mutable iterators,例如int* pi。

当我们对一个 mutable iterators做提领动作时,获得的不应该是个右值(rvalue),应该是个左值(lvalue),因为右值不允许赋值动作(assignment),左值才允许:

1
2
3
4
5
int* pi = new int(5); 
const int* pci = new int(9);
*pi = 7; // 对 mutable iterator做提领动作时,获得的应该是个左值,允许赋值。
*pci = 1; // 这个动作不允许,因为 pci是个constant iterator,
// 提领 pci所得结果,是个右值,不允许被赋值。

在 C++中,函式如果要传回左值,都是以by reference的方式进行,所以当p是个 mutable iterators时,如果其value type是T,那么*p的型别不应该是T,应该是 T&(因为传回引用才是左值,才能进行赋值)。将此道理扩充,如果 p是一个 constant iterators,其value type是 T,那么*p的型别不应该是const T,而应该是const T&。这里所讨论的*p的型别,即所谓的reference type。实作细节将在下一小节一并展示。

迭代器相应型别之四:pointer type

pointers和 references 在 C++中有非常密切的关连。如果「传回一个左值,令它代表p所指之物」是可能的,那么「传回一个左值,令它代表p所指之物的位址」也一定可以。也就是说我们能够传回一个 pointer,指向迭代器所指之物。

这些相应型别已在先前的ListIter class中出现过:

1
2
Item& operator*() const { return *ptr; } 
Item* operator->() const { return ptr; }

Item&便是 ListIter的reference type而 Item*便是其pointer type

现在把 reference typepointer type 这两个相应型别加入traits内:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template <class I> 
struct iterator_traits {
...
typedef typename I::pointer pointer;
typedef typename I::reference reference;
};
//针对原生指标而设计的「偏特化版(partial specialization)」
template <class T>
struct iterator_traits<T*> {
...
typedef T* pointer;
typedef T& reference;
};
//针对原生的 pointer-to-const 而设计的「偏特化版(partial specialization)」
template <class T>
struct iterator_traits<const T*> {
...
typedef const T* pointer;
typedef const T& reference;
};

迭代器相应型别之五:iterator_category

最后一个(第五个)迭代器相应型别会引发较大规模的写码工程。在那之前,必须先讨论迭代器的分类。

根据移动特性与施行动作,迭代器被分为五类:(注:这里的只读和只写或读写,可以理解为作为左值还是右值

  • Input Iterator:这种迭代器所指对象,不允许外界改变。只读(read only)
  • Output Iterator:唯写(write only)
  • Forward Iterator:允许「写入型」算法(例如replace())在此种迭代器所形成的区间上做读写动作。
  • Bidirectional Iterator:可双向移动。某些算法需要逆向走访某个迭代器区间(例如逆向拷贝某范围内的元素),就可以使用 Bidirectional Iterators。
  • Random Access Iterator:前四种迭代器都只供应一部份指标算术能力(前三 种支持operator++,第四种再加上operator–),第五种则涵盖所有指标算术能力,包括p+n, p-n, p[n], p1-p2, p1<p2。

这些迭代器的分类与从属关系,可以用下图表示。直线与箭头代表的并非 C++ 的继承关系,而是所谓concept(概念)与refinement(强化)的关系。

image-20220624144559043

设计算法时,如果可能,我们尽量针对图中的某种迭代器提供一个明确定义,并针对更强化的某种迭代器提供另一种定义,这样才能在不同情况下提供最大效率。研究STL 的过程中,每一分每一秒我们都要明确,效率是个重要课题。假设有个算法可接受Forward Iterator,你以Random Access Iterator喂给它,它当然也会接受,因为一个Random Access Iterator必然是一个Forward Iterator(如图)。但是可用并不代表最佳!

以 advanced()为例

拿advance() 来说(这是许多算法内部常用的一个函式),此函式有两个参数,迭代器p和数值n;函式内部将p累进n次(前进n距离)。下面有三份定义,一份针对Input Iterator,一份针对Bidirectional Iterator,另一份针对Random Access Iterator。倒是没有针对ForwardIterator而设计的版本,因为那和针对InputIterator而设计的版本完全一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template <class InputIterator, class Distance> 
void advance_II(InputIterator& i, Distance n)
{
// 单向,逐一前进
while (n--) ++i; //或写 for ( ; n > 0; --n, ++i );
}
template <class BidirectionalIterator, class Distance>
void advance_BI(BidirectionalIterator& i, Distance n)
{
// 双向,逐一前进
if (n >= 0)
while (n--) ++i;
else
while (n++) --i;
}
//或写 for ( ; n > 0; --n, ++i );
//或写 for ( ; n < 0; ++n, --i );
template <class RandomAccessIterator, class Distance>
void advance_RAI(RandomAccessIterator& i, Distance n)
{
// 双向,跳跃前进
i += n;
}

现在,当程序呼叫 advance(),应该选用(呼叫)哪一份函式定义呢?如果选择advance_II(),对Random Access Iterator而言极度缺乏效率,原本O(1)的操作竟成为O(N)。如果选择advance_RAI(),则它无法接受Input Iterator。我们需要将三者合一,下面是一种作法:

1
2
3
4
5
6
7
8
9
10
template <class InputIterator, class Distance> 
void advance(InputIterator& i, Distance n)
{
if (is_random_access_iterator(i)) //此函式有待设计
advance_RAI(i, n);
else if (is_bidirectional_iterator(i))//此函式有待设计
advance_BI(i, n);
else
advance_II(i, n);
}

但是像这样在执行时期才决定使用哪一个版本,会影响程序效率。最好能够在编译期就选择正确的版本。多载化函式机制可以达成这个目标:

前述三个advance_xx()都有两个函式参数,型别都未定(因为都是 template参数)。为了令其同名,形成多载化函式,我们必须加上一个型别已确定的函式参数,使函式多载化机制得以有效运作起来。

设计考虑如下:如果traits有能力萃取出迭代器的种类,我们便可利用这个「迭代器类型」相应型别做为advanced()的第三参数。这个相应型别一定必须是个class type,不能只是数值号码类的东西,因为编译器需仰赖它(一个型别)来进行多载化决议程序(overloaded resolution)。下面定义五个 classes,代表五种迭代器类型:

1
2
3
4
5
6
//五个做为标记用的型别(tag types)
struct input_iterator_tag { };
struct output_iterator_tag { };
struct forward_iterator_tag : public input_iterator_tag { }; //继承,见后面的分析
struct bidirectional_iterator_tag : public forward_iterator_tag { }; //继承
struct random_access_iterator_tag : public bidirectional_iterator_tag { };//继承

这些 classes只做为标记用,所以不需要任何成员。至于为什么运用继承机制,稍后再解释。现在重新设计 __advance()(由于只在内部使用,所以函式名称加上特定的前导符),并加上第三参数,使它们形成多载化:

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
template <class InputIterator, class Distance> 
inline void __advance(InputIterator& i, Distance n, input_iterator_tag)
{
// 单向,逐一前进
while (n--) ++i;
}
//这是一个单纯的转呼叫函式(trivial forwarding function)。稍后讨论如何免除之。
template <class ForwardIterator, class Distance>
inline void __advance(ForwardIterator& i, Distance n, forward_iterator_tag)
{
// 单纯地进行转呼叫(forwarding),注:可以透过继承消除,直接做InputIterator版的,这个ForwardIterator版可以不写
advance(i, n, input_iterator_tag());
}
template <class BidiectionalIterator, class Distance>
inline void __advance(BidiectionalIterator& i, Distance n, bidirectional_iterator_tag)
{
// 双向,逐一前进
if (n >= 0)
while (n--) ++i;
else
while (n++) --i;
}
template <class RandomAccessIterator, class Distance>
inline void __advance(RandomAccessIterator& i, Distance n, random_access_iterator_tag)
{
// 双向,跳跃前进
i += n;
}

注意上述语法,每个 __advance()的最后一个参数都只宣告型别,并未指定参数名称,因为它纯粹只是用来启动多载化机制,函式之中根本不使用该参数。如果硬要加上参数名称也可以,画蛇添足罢了。

行进至此,还需要一个对外开放的上层控制介面,呼叫上述各个多载化的__advance()。此一上层介面只需两个参数,当它准备将工作转给上述的__advance()时,才自行加上第三自变量:迭代器类型。因此,这个上层函式必须有能力从它所获得的迭代器中推导出其类型—这份工作自然是交给 traits 机制:

1
2
3
4
5
template <class InputIterator, class Distance> 
inline void advance(InputIterator& i, Distance n)
{
__advance(i, n, iterator_traits<InputIterator>::iterator_category());//括号是为了产生暂时对象,否则只是一个类型名称
}

注意上述语法,iterator_traits<Iterator>::iterator_category() 将产生一个暂时对象(道理就像 int()会产生一个 int 暂时对象一样),其型别应该隶属前述五个迭代器类型之一。然后,根据这个型别,编译器才决定呼叫哪一个__advance()多载函式。

因此,为了满足上述行为,traits必须再增加一个相应型别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template <class I> 
struct iterator_traits {
...
typedef typename I::iterator_category iterator_category; //新增加的
};
//针对原生指标而设计的「偏特化版(partial specialization)」
template <class T>
struct iterator_traits<T*> {
...
// 注意,原生指标是一种 Random Access Iterator
typedef random_access_iterator_tag iterator_category; //新增加的
};
//针对原生的 pointer-to-const 而设计的「偏特化版(partial specialization)」
template <class T>
struct iterator_traits<const T*>
...
// 注意,原生的 pointer-to-const是一种 Random Access Iterator
typedef random_access_iterator_tag iterator_category; //新增加的
};

任何一个迭代器,其类型永远应该落在「该迭代器所隶属之各种类型中,最强化的那个」。例如int*既是Random Access Iterator又是 Bidirectional Iterator,同时也是Forward Iterator,而且也是Input Iterator,那么,其类型应该归属为random_access_iterator_tag

你是否注意到advance()的 template参数名称取得好像不怎么理想:

1
2
template <class InputIterator, class Distance> 
inline void advance(InputIterator& i, Distance n);

按说advanced()既然可以接受各种类型的迭代器,就不应将其型别参数命名为InputIterator。这其实是 STL 算法的一个命名规则:以算法所能接受之最低阶迭代器类型,来为其迭代器型别参数命名。(注:毕竟只是命名,传入之后可以自动推导类型)

**消除「单纯转呼叫函式」 **

class 来定义迭代器的各种分类标签,不仅可以促成多载化机制的成功运作(使编译器得以正确执行多载化决议程序,overloaded resolution),另一个好处是,透过继承,我们可以不必再写「单纯只做转呼叫」的函式(例如前述的advance() ForwardIterator版)。考虑下面这个小例子,从其输出结果可以看出端倪:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// file: 3tag-test.cpp 
//模拟测试 tag types继承关系所带来的影响。
#include <iostream>
using namespace std;
struct B { };
struct D1 : public B { };
// B 可比拟为 InputIterator
// D1 可比拟为 ForwardIterator
struct D2 : public D1 { }; // D2 可比拟为 BidirectionalIterator
template <class I>
func(I& p, B)
{ cout << "B version" << endl; }
template <class I>
func(I& p, D2)
{ cout << "D2 version" << endl; }
int main()
{
int* p;
func(p, B()); // 参数与自变量完全吻合。输出: "B version"
func(p, D1()); // 参数与自变量未能完全吻合;因继承关系而自动转呼叫。输出:"B version"
func(p, D2()); // 参数与自变量完全吻合。输出: "D2 version"
}

image-20220624151349583

以 distance()为例

关于「迭代器类型标签」的应用,以下再举一例。distance() 也是常用的一个迭代器操作函式,用来计算两个迭代器之间的距离。针对不同的迭代器类型,它可以有不同的计算方式,带来不同的效率。整个设计模式和前述的advance()如出一辙:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template <class InputIterator> 
inline iterator_traits<InputIterator>::difference_type
__distance(InputIterator first, InputIterator last, input_iterator_tag) {
iterator_traits<InputIterator>::difference_type n = 0;
// 逐一累计距离
while (first != last) {
++first; ++n;
}
return n;
}
template <class RandomAccessIterator>
inline iterator_traits<RandomAccessIterator>::difference_type
__distance(RandomAccessIterator first, RandomAccessIterator last, random_access_iterator_tag) {
// 直接计算差距
return last - first;
}
template <class InputIterator>
inline iterator_traits<InputIterator>::difference_type
distance(InputIterator first, InputIterator last) {
typedef typename iterator_traits<InputIterator>::iterator_category category;
return __distance(first, last, category());
}

注意,distance()可接受任何类型的迭代器;其 template型别参数之所以命名为InputIterator,是为了遵循STL 算法的命名规则:以算法所能接受之最初级类型来为其迭代器型别参数命名。

此外也请注意,由于迭代器类型之间存在着继承关系,「转呼叫(forwarding)」的行为模式因此自然存在——这一点已在前一节讨论过。换句话说,当客端呼叫distance()并使用 Output Iterators 或 Forward Iterators 或Bidirectional Iterators,统统都会转呼叫 Input Iterator版的那个__distance() 函式。(注:这时因为有继承,就不用对这3类迭代器写单纯的呼叫函数了)

std::iterator 的保证

为了符合规范,任何迭代器都应该提供五个内嵌相应型别,以利traits萃取,否则便是自外于整个STL架构,可能无法与其它 STL 组件顺利搭配。然而写码难免挂一漏万,谁也不能保证不会有粗心大意的时候。如果能够将事情简化,就好多了。STL提供了一个iterators class如下,如果每个新设计的迭代器都继承自它,就保证符合 STL 所需之规范:

1
2
3
4
5
6
7
8
9
10
11
12
template <class Category, 
class T,
class Distance = ptrdiff_t,
class Pointer = T*,
class Reference = T&>
struct iterator {
typedef Category iterator_category;
typedef T value_type;
typedef Distance difference_type;
typedef Pointer pointer;
typedef Referencereference;
};

iterator class不含任何成员,纯粹只是型别定义,所以继承它并不会招致任何额外负担。由于后三个参数皆有默认值,新的迭代器只需提供前两个参数即可。

先前的 ListIter,如果改用正式规格,应该这么写:

1
2
3
4
template <class Item> 
struct ListIter :
public std::iterator<std::forward_iterator_tag, Item>
{ ... }

iterator 源码完整重列

以下重新列出 SGI STL <stl_iterator.h>头文件内与本章相关的程序代码。

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
//节录自 SGI STL <stl_iterator.h> 
//五种迭代器类型
struct input_iterator_tag {};
struct output_iterator_tag {};
struct forward_iterator_tag : public input_iterator_tag {};
struct bidirectional_iterator_tag : public forward_iterator_tag {};
struct random_access_iterator_tag : public bidirectional_iterator_tag {};
//为避免写码时挂一漏万,自行开发的迭代器最好继承自下面这个 std::iterator
template <class Category, class T, class Distance = ptrdiff_t,
class Pointer = T*, class Reference = T&>
struct iterator {
typedef Category iterator_category;
typedef T value_type;
typedef Distance difference_type;
typedef Pointer pointer;
typedef Reference reference;
};
//「萃取机」traits
template <class Iterator>
struct iterator_traits {
typedef typename Iterator::iterator_category iterator_category;
typedef typename Iterator::value_type value_type;
typedef typename Iterator::difference_type difference_type;
typedef typename Iterator::pointer pointer;
typedef typename Iterator::reference reference;
};
//针对原生指标(native pointer)而设计的 traits 偏特化版。
template <class T>
struct iterator_traits<T*> {
typedef random_access_iterator_tag iterator_category;
typedef T value_type;
typedef ptrdiff_t difference_type;
typedef T* pointer;
typedef T& reference;
};
//针对原生之pointer-to-const 而设计的 traits 偏特化版。
template <class T>
struct iterator_traits<const T*> {
typedef random_access_iterator_tag iterator_category;
typedef T value_type;
typedef ptrdiff_t difference_type;
typedef const T* pointer;
typedef const T& reference;
};

//这个函式可以很方便地决定某个迭代器的类型(category)
template <class Iterator>
inline typename iterator_traits<Iterator>::iterator_category
iterator_category(const Iterator&) {
typedef typename iterator_traits<Iterator>::iterator_category category;
return category();
}
//这个函式可以很方便地决定某个迭代器的 distance type
template <class Iterator>
inline typename iterator_traits<Iterator>::difference_type*
distance_type(const Iterator&) {
return static_cast<typename iterator_traits<Iterator>::difference_type*>(0);
}
//这个函式可以很方便地决定某个迭代器的 value type
template <class Iterator>
inline typename iterator_traits<Iterator>::value_type*
value_type(const Iterator&) {
return static_cast<typename iterator_traits<Iterator>::value_type*>(0);
}
//以下是整组 distance 函式
template <class InputIterator>
inline iterator_traits<InputIterator>::difference_type
__distance(InputIterator first, InputIterator last, input_iterator_tag) {
iterator_traits<InputIterator>::difference_type n = 0;
while (first != last) {
++first; ++n;
}
return n;
}
template <class RandomAccessIterator>
inline iterator_traits<RandomAccessIterator>::difference_type
__distance(RandomAccessIterator first, RandomAccessIterator last, random_access_iterator_tag) {
return last - first;
}
template <class InputIterator>
inline iterator_traits<InputIterator>::difference_type
distance(InputIterator first, InputIterator last) {
typedef typename iterator_traits<InputIterator>::iterator_category category;
return __distance(first, last,category());
}
//以下是整组 advance函式
template <class InputIterator, class Distance>
inline void __advance(InputIterator& i, Distance n, input_iterator_tag) {
while (n--) ++i;
}
template <class BidirectionalIterator, class Distance>
inline void __advance(BidirectionalIterator& i, Distance n, bidirectional_iterator_tag) {
if (n >= 0)
while (n--) ++i;
else
while (n++) --i;
}
template <class RandomAccessIterator, class Distance>
inline void __advance(RandomAccessIterator& i, Distance n, random_access_iterator_tag) {
i += n;
}
template <class InputIterator, class Distance>
inline void advance(InputIterator& i, Distance n) {
__advance(i, n, iterator_category(i));
}

SGI STL 的私房菜: __type_traits (选看)

traits编程技法很棒,适度弥补了 C++ 语言本身的不足。STL只对迭代器加以规范,制定出iterator_traits这样的东西。SGI 把这种技法进一步扩大到迭代器以外的世界,于是有了所谓的**__type_traits。双底线前缀词意指这是SGI STL 内部所用的东西,不在 STL 标准规范之内**。

iterator_traits负 责萃 取 迭 代器 的特 性,__type_traits 则负责萃取型别(type)的特性。此处我们所关注的型别特性是指:这个型别是否具备non-trivial defalt ctor ?是否具备 non-trivial copy ctor?是否具备 non-trivialassignment operator?是否具备 non-trivialdtor?如果答案是否定的,我们在对这个型别进行建构、解构、拷贝、赋值等动作时,就可以采用最有效率的措施(例如根本不唤起那些constructor, destructor),而采用内存直接处理动作如malloc()、memcpy()等等,获得最高效率。这对于大规模而动作频繁的容器,有着显著的效率提升。

定义于 SGI <type_traits.h>中的__type_traits,提供了一种机制,允许针对不同的型别属性(type attributes),在编译时期完成函式派送决定(function dispatch)。这对于撰写 template很有帮助,例如,当我们准备对一个「元素型别未知」的数组执行 copy 动作时,如果我们能事先知道其元素型别是否有一个 trivial copy constructor , 便 能 够 帮 助 我 们 决 定 是 否 可 使 用 快 速 的memcpy()或memmove()。

从iterator_traits得来的经验,我们希望,程式之中可以这样运用__type_traits<T>,T代表任意型别:

1
2
3
4
5
__type_traits<T>::has_trivial_default_constructor 
__type_traits<T>::has_trivial_copy_constructor
__type_traits<T>::has_trivial_assignment_operator
__type_traits<T>::has_trivial_destructor
__type_traits<T>::is_POD_type

我们希望上述式子响应我们「真」或「假」(以便我们决定采取什么策略),但其结果不应该只是个bool值,应该是个有着真/假性质的「对象」,因为我们希望利用其响应结果来进行自变量推导,而编译器只有面对 class object形式的自变量,才会做自变量推导。为此,上述式子应该传回这样的东西:

1
2
struct __true_type { }; 
struct __false_type { };

这两个空白 classes没有任何成员,不会带来额外负担,却又能够标示真假,满足我们所需。

为了达成上述五个式子, __type_traits内必须定义一些typedefs,其值不是__true_type就是__false_type。下面是 SGI的作法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template <class type> 
struct __type_traits
typedef __true_type this_dummy_member_must_be_first;
/* 不要移除这个成员。它通知「有能力自动将 __type_traits 特化」
的编译器说,我们现在所看到的这个 __type_traits template 是特
殊的。这是为了确保万一编译器也使用一个名为 __type_traits而其
实与此处定义并无任何关联的 template 时,所有事情都仍将顺利运作。
*/
/* 以下条件应被遵守,因为编译器有可能自动为各型别产生专属的 __type_traits
特化版ᴀ:
- 你可以重新排列以下的成员次序
- 你可以移除以下任何成员
- 绝对不可以将以下成员重新命名而却没有改变编译器中的对应名称
- 新加入的成员会被视为一般成员,除非你在编译器中加上适当支持。*/
typedef __false_type has_trivial_default_constructor;
typedef __false_type has_trivial_copy_constructor;
typedef __false_type has_trivial_assignment_operator;
typedef __false_type has_trivial_destructor;
typedef __false_type is_POD_type;
};

为什么SGI 把所有内嵌型别都定义为__false _type呢?是的,SGI 定义出最保守的值,然后(稍后可见)再针对每一个纯量型别(scalar types)设计适当的__type_traits特化版本,这样就解决了问题。

上述 __type_traits可以接受任何型别的自变量,五个typedefs将经由以下管道获得实值:

一般具现体(general instantiation),内含对所有型别都必定有效的保守值。 上述各个has_trivial_xxx型别都被定义为__false_type,就是对所有型别都必定有效的保守值。经过宣告的特化版本,例如<type_traits.h> 内对所有 C++纯量型别(scalar types)提供了对映的特化宣告。这里源码不做展示了。

__types_traits在SGI STL中的应用很广。下面我举几个实例。第一个例子是uninitialized_fill_n()全域函式:

1
2
3
4
5
template <class ForwardIterator, class Size, class T> 
inline ForwardIteratoruninitialized_fill_n(ForwardIterator first,
Size n, const T& x) {
return __uninitialized_fill_n(first, n, x, value_type(first));
}

此函式以 x为蓝本,自迭代器first开始建构 n个元素。为求取最大效率,首先 以value_type()萃取出迭代器first的value type,再利用__type_traits判断该型别是否为 POD型别:

1
2
3
4
5
6
template <class ForwardIterator, class Size, class T, class T1> 
inline ForwardIterator __uninitialized_fill_n(ForwardIterator first, Size n, const T& x, T1*)
{
typedef typename __type_traits<T1>::is_POD_type is_POD;
return __uninitialized_fill_n_aux(first, n, x, is_POD());
}

以下就「是否为 POD型别」采取最适当的措施:

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
//如果不是 POD型别,就会派送(dispatch)到这里
template <class ForwardIterator, class Size, class T>
ForwardIterator
__uninitialized_fill_n_aux(ForwardIterator first, Size n, const T& x, __false_type) {
ForwardIterator cur = first;
// 为求阅读顺畅简化,以下将原本有的异常处理(exception handling)去除。
for ( ; n > 0; --n, ++cur)
construct(&*cur, x);
return cur;
}
//如果是 POD型别,就会派送(dispatch)到这里。下两行是原文件所附注解。
//如果 copy construction等同于 assignment,而且有 trivial destructor,
//以下就有效。
template <class ForwardIterator, class Size, class T>
inline ForwardIterator
__uninitialized_fill_n_aux(ForwardIterator first, Size n, const T& x, __true_type) {
return fill_n(first, n, x);//交由高阶函式执行,如下所示。
}
//以下是定义于 <stl_algobase.h> 中的 fill_n()
template <class OutputIterator, class Size, class T>
OutputIteratorfill_n(OutputIterator first, Size n, const T& value) {
for ( ; n > 0; --n, ++first)
*first = value;
return first;
}

因此如果你是 SGI STL的使用者,你可以在自己的程式中充份运用这个__type_traits。假设我自行定义了一个Shape class,__type_traits 会对它产生什么效应?如果编译器够厉害(例如Silicon Graphics 的N32 和 N64 编译器),你会发现,__type_traits针对Shape萃取出来的每一个特性,其结果将取决于我的Shape是否有 trivialdefalt ctor或trivialcopy ctor或trivial assignment operator或 trivialdtor而定。但对大部份缺乏这种特异功能的编译器而言,__type_traits 针对 Shape 萃取出来的每一个特性都是__false_type ,即使Shape是个 POD型别。这样的结果当然过于保守,但是别无选择,除非我针对 Shape,自行设计一个__type_traits 特化版本,明白地告诉编译器以下事实(举例):

1
2
3
4
5
6
7
template<>struct __type_traits<Shape> { 
typedef __true_type has_trivial_default_constructor;
typedef __false_type has_trivial_copy_constructor;
typedef __false_type has_trivial_assignment_operator;
typedef __false_type has_trivial_destructor;
typedef __false_type is_POD_type;
};

究竟一个 class什么时候该有自己的 non-trivial default constructor, non-trivial copy constructor, non-trivial assignment operator, non-trivial destructor 呢?一个简单的判断准则是:如果 class 内含指标成员,并且对它进行内存动态配置,那么这个class就需要实作出自己的 non-trivial-xxx。

即使你无法全面针对你自己定义的型别,设计__type_traits特化版本,无论如何,至少,有了这个__type_traits之后,当我们设计新的泛型算法时,面对C++纯量型别,便有足够的信息决定采用最有效的拷贝动作或赋值动作—因为每一个纯量型别都有对应的__type_traits 特化版ᴀ,其中每一个 typedef 的值都是__true_type。

总结

这章主要介绍迭代器,迭代器是一个行为类似指针的对象,重载了*和->的动作。特别的,关于重载*,涉及到了左值和右值,这里*动作提领的内容是允许赋值的,因此是左值,故而operator函数需要返回的类型是T&,是引用,否则只是创建了临时对象(可以作为右值)。迭代器为了走访容器,要重载++动作;为了判别元素,要重载==、!=动作。

在算法中,可能需要临时对象,其类型是迭代器指向对象的类型,这就需要能够根据迭代器知道这个类型。函数模板可以自动推导类型,然而这仅能推导一种类型,并且无法推导返回类型。使用traits编程技法可以更加全面。基本思想是在迭代器类内定义好类型(比如value_type),因为迭代器的模板T就告知了这样的类型。那么在算法内就可以使用这个由迭代器定义出的类型。但不是所有类型都可以自己来定义,原生类(如int)就没办法。因此需要traits来进行中间介入:使用模板偏特化。如果是自己定义的类,那么traits出来的就是类定义的value_type,如果是原生指针T*或const指针(会进入偏特化版而不使用泛化版),则萃取出来的是指针原类型T。

进一步,迭代器也有自己的类型,不同类型支持不同的操作,对算法而言一个确定的类型可以拥有最好的效率。与前面一样,可以每个迭代器定义自己的tag,用来选择执行算法不同的版本。然而为了在编译时就确定执行哪个版本(而不是用if这种,在运行才确定),要使用函数多载化,具体就是用tag作为一个新的参数,这个参数没有名称,纯粹用来选择版本,传入时加括号产生临时对象即可。依然有偏特化版本,原生指针是Random Access Iterator。

然后,为了避免单纯转呼叫动作,比如forward迭代器版本也用的是input迭代器版本的算法,如果只按上面的讨论,forward迭代器版本的算法要转呼叫input迭代器的算法。通过继承可以省去这一步,如果forward版本未定义,发现其父类input版本有定义,就自动执行input版本。这个继承被允许是因为高层级的迭代器都支持低层级迭代器的操作,呼叫低层级迭代器算法不会有冲突。再者,因为要用继承,因此tag必须是一个类(class或strut),这些tag类是预定义好的。为了方便迭代器设计,可以继承std::iterator这个类。

最后,SGI STL不只使用了5个type的traits,扩展到了更大的范围,不过原理是一样的,作用依然是提高效率。

序列式容器

容器的概观与分类

研究数据的特定排列方式,以利搜寻或排序或其它特殊目的,这一专门学科我们称为数据结构(Data Structures)。大学信息相关教育里头,与编程最有直接关系的科目,首推数据结构与算法(Algorithms)。几乎可以说,任何特定的数据结构都是为了实现某种特定的算法。STL 容器即是将运用最广的一些数据结构实作出来。

常用的数据结构不外乎 array(数组)、list(串行)、tree(树)、stack (堆栈)、queue(队列)、hash table(杂凑表)、set(集合)、map(映像表)等等。根据「资料在容器中的排列」特性,这些数据结构分为序列式(sequence)和关系型(associative)两种。

序列式容器(sequential containers)

所谓序列式容器,其中的元素都可序(ordered),但未排序(sorted)。C++ 语言本身提供了一个序列式容器array,STL另外再提供vector,list,deque, stack,queue,priority-queue等等序列式容器。其中stack和queue由于只是将deque改头换面而成,技术上被归类为一种配接器(adapter),但仍把它们放在本章讨论。本章将带你仔细看过各种序列式容器的关键实作细节。

vector

vector 概述

vector的数据安排以及操作方式,与array非常像似。两者的唯一差别在于空间的运用弹性。array是静态空间,一旦配置了就不能改变;如果要换个大(或小) 一点的空间,一切细琐需由客端自己来:首先配置一块新空间,然后将元素从旧址一一搬往新址,然后再把原来的空间释还给系统。vector是动态空间,随着元素的加入,它的内部机制会自行扩充空间以容纳新元素。因此,vector的运用对于内存的樽节与运用弹性有很大的帮助,我们再也不必因为害怕空间不足而一开始就要求一个大块头array了,我们可以安心使用vector,需要多少用多少。

vector的实作技术,关键在于其对大小的控制以及重新配置时的数据搬移效率。

一旦vector旧有空间满载,如果客端每新增一个元素,vector内部只是扩充一 个元素的空间,实为不智,因为所谓扩充空间(不论多大),是「配置新空间 /数据搬移 /释还旧空间」的大工程,时间成本很高,应该加入某种未雨绸缪的考虑。

vector 定义式摘要

以下是vector定义式的源码摘录。虽然 STL规定,欲使用vector者必须先含入<vector>,但 SGI STL 将vector实作于更底层的<stl_vector.h>

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
// alloc是 SGI STL的空间配置器,见第二章。
template <class T, class Alloc = alloc>
class vector {
public:
// vector 的内嵌型别定义
typedef T value_type;
typedef value_type* pointer;
typedef value_type* iterator;
typedef value_type& reference;
typedef size_t size_type;
typedef ptrdiff_t difference_type;
protected:
// 以下,simple_alloc 是 SGI STL的空间配置器,见 2.2.4节。
typedef simple_alloc<value_type,Alloc> data_allocator;
iterator start;
iterator finish;
//表示目前使用空间的头
//表示目前使用空间的尾
iterator end_of_storage; //表示目前可用空间的尾
void insert_aux(iterator position, const T& x);
void deallocate() {
if (start)
data_allocator::deallocate(start, end_of_storage - start);
}
void fill_initialize(size_type n, const T& value) {
start = allocate_and_fill(n, value);
finish = start + n;
end_of_storage = finish;
}
public:
iterator begin() { return start; }
iterator end() { return finish; }
size_type size() const { return size_type(end() - begin()); }
size_type capacity() const {
return size_type(end_of_storage - begin()); }
bool empty() const { return begin() == end(); }
reference operator[](size_type n) { return *(begin() + n); }
//构造函数
vector() : start(0), finish(0), end_of_storage(0) {}
vector(size_type n, const T& value) {fill_initialize(n, value); }
vector(int n, const T& value) { fill_initialize(n, value); }
vector(long n, const T& value) { fill_initialize(n, value); }
explicit vector(size_type n) { fill_initialize(n, T()); } //注:T()产生临时对象
~vector() {
destroy(start, finish); //全域函式,见 2.2.3节。注:消除元素
deallocate(); // 这是 vector 的一个 member function 。注:回收内存
}

reference front() { return *begin(); } //第一个元素
reference back() { return *(end() - 1); }//最后一个元素
void push_back(const T& x) {
if (finish != end_of_storage) {
//将元素安插至最尾端
construct(finish, x); //全域函式,见 2.2.3节。注:这里内存已经有了,直接建构元素
++finish;
}
else
insert_aux(end(), x); // 这是 vector 的一个 member function
}

void pop_back() { //将最尾端元素取出
--finish;
destroy(finish); //全域函式,见 2.2.3节。
}

iterator erase(iterator position) { //清除某位置上的元素
if (position + 1 != end()) //注:如果不是最后一个元素
copy(position + 1, finish, position);//后续元素往前搬移
--finish;
destroy(finish); //全域函式,见 2.2.3节。
return position;
}

void resize(size_type new_size, const T& x) {
if (new_size < size())
erase(begin() + new_size, end());
else
insert(end(), new_size - size(), x);
}
void resize(size_type new_size) {resize(new_size, T()); }
void clear() { erase(begin(), end()); }
protected:
// 配置空间并填满内容
iterator allocate_and_fill(size_type n, const T& x) {
iterator result =data_allocator::allocate(n); //分配内存
uninitialized_fill_n(result, n, x); // 全域函式,见 2.3 节
return result;
}

vector 的迭代器

vector维护的是一个连续线性空间,所以不论其元素型别为何,普通指针都可以做为 vector的迭代器而满足所有必要条件,因为 vector 迭代器所需要的操作行为,如 operator*,operator->,operator++,operator–,operator+, operator-, operator+=,operator-=,普通指针天生就具备。vector支持随机存取,而普通指针正有着这样的能力。所以,vector 提供的是 Random Access Iterators

1
2
3
4
5
6
7
template <class T, class Alloc = alloc> 
class vector {
public:
typedef T value_type;
typedef value_type* iterator; // vector 的迭代器是普通指针
...
};

根据上述定义,如果客端写出这样的码:

1
2
vector<int>::iterator ivite; 
vector<Shape>::iterator svite;

ivite的型别其实就是int*,svite的型别其实就是 Shape*。

vector 的数据结构

vector所采用的数据结构非常简单:线性连续空间。它以两个迭代器start和finish分别指向配置得来的连续空间中目前已被使用的范围,并以迭代器end_of_storage指向整块连续空间(含备用空间)的尾端:

1
2
3
4
5
6
7
8
9
template <class T, classAlloc = alloc> 
class vector {
...
protected:
iterator start; //表示目前使用空间的头
iterator finish; //表示目前使用空间的尾
iterator end_of_storage; //表示目前可用空间的尾,注意“使用”与“可用”之间的差别
...
};

为了降低空间配置时的速度成本,vector实际配置的大小可能比客端需求量更大一些,以备将来可能的扩充。这便是容量(capacity)的观念。换句话说一个 vector 的容量永远大于或等于其大小。一旦容量等于大小,便是满载,下次再有新增元素,整个vector就得另觅居所。

运用start, finish, end_of_storage三个迭代器,便可轻易提供首尾标示、大小、容量、空容器判断、注标([ ])运算子、最前端元素值、最后端元素值…等机能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <class T, classAlloc = alloc> 
class vector {
...
public:
iterator begin() { return start; }
iterator end() { return finish; }
size_type size() const { return size_type(end() - begin()); }
size_type capacity() const {
return size_type(end_of_storage - begin()); }
bool empty() const { return begin() == end(); }
reference operator[](size_type n) { return*(begin() + n); } //重载[]运算
reference front() { return *begin(); }
reference back() { return *(end() - 1); }
...
};

大致的示意图如下:

image-20220628153235013

vector 的建构与内存管理:constructor, push_back

下面是个小小的测试程序,观察重点在建构的方式、元素的添加,以及大小、容量的变化:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// filename : 4vector-test.cpp 
#include <vector>
#include <iostream>
#include <algorithm>
using namespace std;
int main()
{
int i;
vector<int> iv(2,9);
cout << "size=" << iv.size() << endl; // size=2
cout << "capacity=" << iv.capacity() << endl; // capacity=2
iv.push_back(1);
cout << "size=" << iv.size() << endl; // size=3
cout << "capacity=" << iv.capacity() << endl; // capacity=4
iv.push_back(2);
cout << "size=" << iv.size() << endl; // size=4
cout << "capacity=" << iv.capacity() << endl; // capacity=4
iv.push_back(3);
cout << "size=" << iv.size() << endl; // size=5
cout << "capacity=" << iv.capacity() << endl; // capacity=8
iv.push_back(4);
cout << "size=" << iv.size() << endl; // size=6
cout << "capacity=" << iv.capacity() << endl; // capacity=8
for(i=0; i<iv.size(); ++i)
cout << iv[i] << ' '; // 9 9 1 2 3 4
cout << endl;
iv.push_back(5);
cout << "size=" << iv.size() << endl; // size=7
cout << "capacity=" << iv.capacity() << endl; // capacity=8
for(i=0; i<iv.size(); ++i)
cout << iv[i] << ' '; // 9 9 1 2 3 4 5
cout << endl;
iv.pop_back();
iv.pop_back();
cout << "size=" << iv.size() << endl; // size=5
cout << "capacity=" << iv.capacity() << endl; // capacity=8
iv.pop_back();
cout << "size=" << iv.size() << endl; // size=4
cout << "capacity=" << iv.capacity() << endl; // capacity=8
vector<int>::iterator ivite =find(iv.begin(), iv.end(), 1);
if (ivite)iv.erase(ivite);
cout << "size=" << iv.size() << endl; // size=3
cout << "capacity=" << iv.capacity() << endl; // capacity=8
for(i=0; i<iv.size(); ++i)
cout << iv[i] << ' '; // 9 9 2
cout << endl;
ite =find(ivec.begin(), ivec.end(), 2);
if (ite) ivec.insert(ite,3,7);
cout << "size=" << iv.size() << endl; // size=6
cout << "capacity=" << iv.capacity() << endl; // capacity=8
for(int i=0; i<ivec.size(); ++i)
cout << ivec[i] << ' '; // 9 9 7 7 7 2
cout << endl;
iv.clear();
cout << "size=" << iv.size() << endl; // size=0
cout << "capacity=" << iv.capacity() << endl; // capacity=8
}

vector预设使用alloc(第二章)做为空间配置器,并据此另外定义了一个data_allocator,为的是更方便以元素大小为配置单位:

1
2
3
4
5
6
7
template <class T, class Alloc = alloc> 
class vector {
protected:
// simple_alloc<> 见 2.2.4 节
typedef simple_alloc<value_type,Alloc> data_allocator;
...
};

于是,data_allocator::allocate(n)表示配置 n 个元素空间。

vector 提供许多constructors,其中一个允许我们指定空间大小及初值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 建构式,允许指定 vector 大小 n和初值 value 
vector(size_type n, const T& value) {fill_initialize(n, value); }
// 充填并予初始化
void fill_initialize(size_type n, const T& value) {
start =allocate_and_fill(n, value);
finish = start + n; //指向下一个空的位置
end_of_storage = finish;
}
// 配置而后充填
iterator allocate_and_fill(size_type n, const T& x) {
iterator result =data_allocator::allocate(n); // 配置 n 个元素空间
uninitialized_fill_n(result, n, x); // 全域函式,见 2.3 节
return result;
}

uninitialized_fill_n()会根据第一参数的型别特性(type traits,3.7 节),决定使用算法 fill_n()或反复呼叫 construct() 来完成任务(见 2.3 节描述)。

当我们以push_back()将新元素安插于vector 尾端,该函式首先检查是否还有备用空间?如果有就直接在备用空间上建构元素,并调整迭代器 finish,使 vector 变大。如果没有备用空间了,就扩充空间(重新配置、搬移数据、释放原空间):

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
void push_back(const T& x) { 
if (finish != end_of_storage) { //还有备用空间
construct(finish, x); //全域函式,见 2.2.3节。
++finish;
}
else //已无备用空间
insert_aux(end(), x); // vector member function,见以下列表
}
template <class T, class Alloc>
void vector<T, Alloc>::insert_aux(iterator position, const T& x) {
if (finish != end_of_storage) {//还有备用空间
// 在备用空间起始处建构一个元素,并以 vector 最后一个元素值为其初值。
construct(finish, *(finish - 1));
// 调整水位。
++finish;
T x_copy = x;
copy_backward(position, finish - 2, finish - 1);
//不要被 copy_backward() 算法的名称所误导,它不会逆转元素的顺序。它只会像 copy() 那样复制元素,但是顺序是从最后一个元素开始直到第一个元素。
*position = x_copy;
}
else { //已无备用空间
const size_type old_size = size();
const size_type len = old_size != 0 ? 2 * old_size : 1;
// 以上配置原则:如果原大小为 0,则配置 1(个元素大小);
// 如果原大小不为 0,则配置原大小的两倍,
// 前半段用来放置原资料,后半段准备用来放置新资料。
iterator new_start =data_allocator::allocate(len); //实际配置
iterator new_finish = new_start;
try { //异常处理模型的trycatch
// 将原 vector 的内容拷贝到新 vector。
new_finish = uninitialized_copy(start, position, new_start); //[start,position),左闭右开
// 为新元素设定初值 x
construct(new_finish, x); //new_finish=position位置,是原来尾巴的下一个未用空间
// 调整水位。
++new_finish;
// 将原 vector 的备用空间中的内容也忠实拷贝过来(作者疑惑:啥用途?)
new_finish =uninitialized_copy(position, finish, new_finish);
//个人理解:实际上这个函数是在某个位置上插入x,只是恰好也有扩充空间的作用,因而拿来push_back()中扩充;
//如果再考虑插入的情况,position不是最后的位置,就需要把原来后面的元素再拷贝过来
}
catch(...) {
// "commit or rollback" semantics.
destroy(new_start, new_finish);
data_allocator::deallocate(new_start, len);
throw;
}
// 解构并释放原 vector
destroy(begin(), end());
deallocate();
// 调整迭代器,指向新 vector
start = new_start;
finish = new_finish;
end_of_storage = new_start + len;
}
}

注意,所谓动态增加大小,并不是在原空间之后接续新空间(因为无法保证原空间之后尚有可供配置的空间),而是以原大小的两倍另外配置一块较大空间,然后将原内容拷贝过来,然后才开始在原内容之后建构新元素,并释放原空间。因此,对vector的任何操作,一旦引起空间重新配置,指向原vector的所有迭代器就都失效了。这是程序员易犯的一个错误,务需小心。

vector 的元素操作:pop_back, erase, clear, insert

vector所提供的元素操作动作很多,无法在有限篇幅中一一讲解——其实也没有这种必要。为搭配先前对空间配置的讨论,这里挑选数个相关函式做为解说对象。这些函式也出现在先前的测试程序中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 将尾端元素拿掉,并调整大小。
void pop_back() {
--finish;
destroy(finish);
}
//将尾端标记往前移一格,表示将放弃尾端元素。
// destroy是全域函式,见第 2 章
// 清除 [first,last) 中的所有元素
iterator erase(iterator first, iterator last) {
iterator i = copy(last, finish, first);// copy 是全域函式,第 6 章
destroy(i, finish);// destroy是全域函式,第 2 章
finish = finish - (last - first);
return first;
}
// 清除某个位置上的元素
iterator erase(iterator position) {
if (position + 1 != end())
copy(position + 1, finish, position); // copy 是全域函式,第 6 章
--finish;
destroy(finish); // destroy是全域函式,2.2.3 节
return position;
}
void clear() { erase(begin(), end()); }// erase()就定义在上面

下图展示 erase(first, last)的动作。

image-20220628163842260

下面是vector::insert()实作内容:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
//从 position 开始,安插 n个元素,元素初值为 x 
template <class T, class Alloc>
void vector<T, Alloc>::insert(iterator position, size_type n, const T& x)
{
if (n != 0) {// 当 n != 0 才进行以下所有动作
if (size_type(end_of_storage - finish) >= n)
// 备用空间大于等于「新增元素个数」
T x_copy = x;
// 以下计算安插点之后的现有元素个数
const size_type elems_after = finish - position;
iterator old_finish = finish;
if (elems_after > n)
// 「安插点之后的现有元素个数」大于「新增元素个数」
uninitialized_copy(finish - n, finish, finish); //分两步后移n位腾出空间
finish += n;//将 vector 尾端标记后移
copy_backward(position, old_finish - n, old_finish); //注:使用这个函数,只需要知道这一段元素的结尾应该copy到哪个位置(因为是从后往前copy),而不需要知道开头位置,实际上开头位置也可以算出来,但使用这个函数就不需要计算
fill(position, position + n, x_copy);//从安插点开始填入新值
}
else {
// 「安插点之后的现有元素个数」小于等于「新增元素个数」
uninitialized_fill_n(finish, n - elems_after, x_copy); //注:先把一些空间填上去先,而不是直接后移腾出空间(不过感觉差不多,因为要移动到的位置下面的finish也计算出来了)
finish += n - elems_after;
uninitialized_copy(position, old_finish, finish); //再把余下占用元素后移
finish += elems_after;
fill(position, old_finish, x_copy);
}
}
else {
// 备用空间小于「新增元素个数」(那就必须配置额外的内存)
// 首先决定新长度:旧长度的两倍,或旧长度+新增元素个数。
const size_type old_size = size();
const size_type len = old_size + max(old_size, n);
// 以下配置新的 vector 空间
iterator new_start = data_allocator::allocate(len);
iterator new_finish = new_start;
__STL_TRY {
// 以下首先将旧 vector的安插点之前的元素复制到新空间。
new_finish = uninitialized_copy(start, position, new_start);
// 以下再将新增元素(初值皆为 n)填入新空间。
new_finish = uninitialized_fill_n(new_finish, n, x);
// 以下再将旧 vector 的安插点之后的元素复制到新空间。
new_finish = uninitialized_copy(position, finish, new_finish);
}
# ifdef __STL_USE_EXCEPTIONS
catch(...) {
// 如有异常发生,实现 "commit or rollback" semantics.
destroy(new_start, new_finish);
data_allocator::deallocate(new_start, len);
throw;
}
# endif /* __STL_USE_EXCEPTIONS */
// 以下清除并释放旧的 vector
destroy(start, finish);
deallocate();
// 以下调整水位标记
start = new_start;
finish = new_finish;
end_of_storage = new_start + len;
}
}
}

注意,安插完成后,新节点将位于标兵迭代器(上例之 position,标示出安插点)所指之节点的前方—这是STL对于「安插动作」的标准规范。

image-20220628165729556

image-20220628165749526

image-20220628165819539

list

list 概述

相较于vector的连续线性空间,list就显得复杂许多,它的好处是每次安插或删除一个元素,就配置或释放一个元素空间。因此,list对于空间的运用有绝对的精准,一点也不浪费。而且,对于任何位置的元素安插或元素移除,list永远是常数时间

list和vector是两个最常被使用的容器。什么时机下最适合使用哪一种容器,必须视元素的多寡、元素的构造复杂度(有无 non-trivial copy constructor, non-trivial copy assignmen operator)、元素存取行为的特性而定。

list 的节点(node)

list本身和 list的节点是不同的结构,需要分开设计。以下是 STL list的节点(node)结构:

1
2
3
4
5
6
7
template <class T> 
struct __list_node {
typedef void* void_pointer;
void_pointer prev; //型别为 void*。其实可设为 __list_node<T>*
void_pointer next;
T data;
};

显然这是一个双向串行(有前后指针)。

list 的迭代器

list不再能够像vector一样以普通指针做为迭代器,因为其节点不保证在储存空间中连续存在。list迭代器必须有能力指向list的节点,并有能力做正确的递增、递减、取值、成员存取…等动作。所谓「list迭代器正确的递增、递减、取值、成员取用」动作是指,递增时指向下一个节点,递减时指向上一个节点,取值时取的是节点的值,成员取用时取用的是节点的成员。

由于STL list是一个双向串行(double linked-list),迭代器必须具备前移、后移的能力。所以list提供的是Bidirectional Iterators。

list有一个重要性质:安插动作(insert)和接合动作(splice)都不会造成原有的list迭代器失效。这在vector是不成立的,因为vector的安插动作可能造成内存重新配置,导致原有的迭代器全部失效。甚至list的元素删除动作(erase),也只有「指向被删除元素」的那个迭代器失效,其它迭代器不受任何影响。

以下是list迭代器的设计:

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
38
39
40
41
42
43
template<class T, class Ref, class Ptr> 
struct __list_iterator {
typedef __list_iterator<T, T&, T*> iterator;
typedef __list_iterator<T, Ref, Ptr> self; //注:感觉self和iterator没多大区别?
typedef bidirectional_iterator_tag iterator_category;
typedef T value_type;
typedef Ptr pointer;
typedef Ref reference;
typedef __list_node<T>* link_type;
typedef size_t size_type;
typedef ptrdiff_t difference_type;
link_type node; //迭代器内部当然要有一个指针,指向 list 的节点
// constructor
__list_iterator(link_type x) : node(x) {}
__list_iterator() {}
__list_iterator(const iterator& x) : node(x.node) {} //注:复制构造函数
bool operator==(const self& x) const { return node == x.node; }
bool operator!=(const self& x) const { return node != x.node; }
// 以下对迭代器取值(dereference),取的是节点的资料值。
reference operator*() const { return (*node).data; }
// 以下是迭代器的成员存取(member access)运算子的标准作法。
pointer operator->() const { return &(operator*()); } //注:这个指针,指向data的地址,如果对它使用*就能取出值
// 对迭代器累加 1,就是前进一个节点
self& operator++()
node = (link_type)((*node).next);
return *this;
}
self operator++(int)
self tmp = *this;
++*this;
return tmp;
}
// 对迭代器递减 1,就是后退一个节点
self& operator--()
node = (link_type)((*node).prev);
return *this;
}
self operator--(int)
self tmp = *this;
--*this;
return tmp;
}
};

list 的数据结构

SGI list不仅是一个双向串行,而且还是一个环状双向串行。所以它只需要一个指标,便可以完整表现整个串行:

1
2
3
4
5
6
7
8
9
10
template <class T, class Alloc = alloc>// 预设使用 alloc 为配置器
class list {
protected:
typedef __list_node<T> list_node;
public:
typedef list_node* link_type;
protected:
link_type node;// 只要一个指标,便可表示整个环状双向串行
...
};

如果让指标node指向刻意置于尾端的一个空白节点,node便能符合 STL对于「前闭后开」区间的要求,成为last迭代器。

示意图如下:

image-20220628201940409

这么一来,以下几个函式便都可以轻易完成:

1
2
3
4
5
6
7
8
9
10
11
12
iterator begin() { return (link_type)((*node).next); } 
iterator end() { return node; }
bool empty() const { return node->next == node; }
size_type size() const {
size_type result = 0;
distance(begin(), end(), result); // 全域函式,第 3 章。
return result;
}
// 取头节点的内容(元素值)。
reference front() { return *begin(); }
// 取尾节点的内容(元素值)。
reference back() { return *(--end()); }

list 的建构与内存管理:constructor, push_back, insert

下面是一个测试程序,观察重点在建构的方式以及大小的变化:

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
// filename : 4list-test.cpp 
#include <list>
#include <iostream>
#include <algorithm>
using namespace std;
int main()
{
int i;
list<int> ilist;
cout << "size=" << ilist.size() << endl; // size=0
ilist.push_back(0);
ilist.push_back(1);
ilist.push_back(2);
ilist.push_back(3);
ilist.push_back(4);
cout << "size=" << ilist.size() << endl; // size=5
list<int>::iterator ite;
for(ite = ilist.begin(); ite != ilist.end(); ++ite)
cout << *ite << ' '; // 0 1 2 3 4
cout << endl;
ite =find(ilist.begin(), ilist.end(), 3);
if (ite!=0)
ilist.insert(ite, 99);
cout << "size=" << ilist.size() << endl; // size=6
cout << *ite << endl; // 3
for(ite = ilist.begin(); ite != ilist.end(); ++ite)
cout << *ite << ' '; // 0 1 2 99 3 4
cout << endl;
ite =find(ilist.begin(), ilist.end(), 1);
if (ite!=0)
cout << *(ilist.erase(ite)) << endl; // 2
for(ite = ilist.begin(); ite != ilist.end(); ++ite)
cout << *ite << ' '; // 0 2 99 3 4
cout << endl;
}

list预设使用alloc 做为空间配置器 , 并据此另外定义了一个list_node_allocator,为的是更方便地以节点大小为配置单位:

1
2
3
4
5
6
7
8
template <class T, class Alloc = alloc>// 预设使用 alloc 为配置器
class list {
protected:
typedef __list_node<T> list_node;
// 专属之空间配置器,每次配置一个节点大小:
typedef simple_alloc<list_node, Alloc> list_node_allocator;
...
};

于是,list_node_allocator(n)表示配置n个节点空间。以下四个函式,分别用来配置、释放、建构、摧毁一个节点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
protected: 
// 配置一个节点并传回
link_type get_node() { return list_node_allocator::allocate(); }
// 释放一个节点
void put_node(link_type p) { list_node_allocator::deallocate(p); }

// 产生(配置并建构)一个节点,带有元素值
link_type create_node(const T& x) {
link_type p = get_node();
construct(&p->data, x); //全域函式,建构/解构基本工具。
return p;
}
// 摧毁(解构并释放)一个节点
void destroy_node(link_type p) {
destroy(&p->data); //全域函式,建构/解构基本工具
put_node(p);
}

list提供有许多constructors,其中一个是default constructor,允许我们不指定任何参数做出一个空的list出来:

1
2
3
4
5
6
7
8
public: 
list() {empty_initialize(); } //产生一个空串行。
protected:
void empty_initialize()
node =get_node(); //配置一个节点空间,令 node 指向它。
node->next = node;//令 node头尾都指向自己,不设元素值。
node->prev = node;
}

image-20220629142749124

当我们以 push_back() 将新元素安插于 list 尾端,此函式内部呼叫insert():

1
void push_back(const T& x) { insert(end(), x); }

insert()是一个多载化函式,有多种型式,其中最简单的一种如下,符合以上所需。首先配置并建构一个节点,然后在尾端做适当的指标动作,将新节点安插进去:

1
2
3
4
5
6
7
8
9
10
// 函式目的:在迭代器 position 所指位置安插一个节点,内容为 x。
iterator insert(iterator position, const T& x) {
link_type tmp =create_node(x); //产生一个节点(设内容为 x)
// 调整双向指标,使 tmp安插进去。
tmp->next = position.node; //注:从这里可以看出是在position前面安插
tmp->prev = position.node->prev;
(link_type(position.node->prev))->next = tmp;
position.node->prev = tmp;
return tmp;
}

注意,安插完成后,新节点将位于标兵迭代器(标示出安插点)所指之节点的前方——这是STL对于「安插动作」的标准规范。由于 list 不像 vector 那样有可能在空间不足时做重新配置、数据搬移的动作,所以安插前的所有迭代器在安插动作之后都仍然有效。

list 的元素操作

元素操作的手段包括:push_front, push_back, erase, pop_front, pop_back, clear, remove, unique, splice, merge, reverse, sort

list所提供的元素操作动作很多,无法在有限的篇幅中一一讲解——其实也没有这种必要。为搭配先前对空间配置的讨论,我挑选数个相关函式做为解说对象。先前示例中出现有尾部安插动作(push_back),现在我们来看看其它的安插动作和移除动作。

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// 安插一个节点,做为头节点
void push_front(const T& x) { insert(begin(), x); }
// 安插一个节点,做为尾节点(上一小节才介绍过)
void push_back(const T& x) { insert(end(), x); }
// 移除迭代器 position 所指节点
iterator erase(iterator position) {
link_type next_node = link_type(position.node->next);
link_type prev_node = link_type(position.node->prev);
prev_node->next = next_node;
next_node->prev = prev_node;
//跟换指针指向的操作都是四个动作
destroy_node(position.node);
return iterator(next_node);
}
// 移除头节点
void pop_front() { erase(begin()); }
// 移除尾节点
void pop_back()
iterator tmp = end();
erase(--tmp);
}
//清除所有节点(整个串行)
template <class T, class Alloc>
void list<T, Alloc>::clear()
{
link_type cur = (link_type) node->next; // begin()
while (cur != node) {//巡访每一个节点
link_type tmp = cur;
cur = (link_type) cur->next;
destroy_node(tmp); //摧毁(解构并释放)一个节点
}
// 恢复 node 原始状态
node->next = node;
node->prev = node;
}
//将数值为 value之所有元素移除
template <class T, class Alloc>
void list<T, Alloc>::remove(const T& value) {
iterator first = begin();
iterator last = end();
while (first != last) {//巡访每一个节点
iterator next = first;
++next;
if (*first == value)erase(first); //找到就移除
first = next;
}
}
//移除数值相同的连续元素。注意,只有「连续而相同的元素」,才会被移除剩一个。
template <class T, class Alloc>
void list<T, Alloc>::unique() {
iterator first = begin();
iterator last = end();
if (first == last) return;//空串行,什么都不必做。
iterator next = first;
while (++next != last) { //巡访每一个节点
if (*first == *next) //如果在此区段中有相同的元素
erase(next); //移除之
else
first = next; //调整指标
next = first; //修正区段范围
}
}

由于list是一个双向环状串行,只要我们把边际条件处理好,那么,在头部或尾部安插元素(push_front和push_back),动作几乎是一样的,在头部或尾部移除元素(pop_front和pop_back),动作也几乎是一样的。移除(erase)某个迭代器所指元素,只是做一些指标搬移动作而已,并不复杂。

list内部提供一个所谓的迁移动作(transfer):将某连续范围的元素迁移到某个特定位置之前。技术上很简单,节点间的指标移动而已。这个动作为其它的复杂动作如splice, sort, merge等奠定良好的基础。下面是transfer的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
protected: 
// 将 [first,last) 内的所有元素搬移到 position 之前。
void transfer(iterator position, iterator first, iterator last) {
if (position != last) {
(*(link_type((*last.node).prev))).next = position.node; // (1)
(*(link_type((*first.node).prev))).next = last.node; // (2)
(*(link_type((*position.node).prev))).next = first.node; // (3)
link_type tmp = link_type((*position.node).prev); // (4)
(*position.node).prev = (*last.node).prev; // (5)
(*last.node).prev = (*first.node).prev; // (6)
(*first.node).prev = tmp; // (7)
}
}

以上七个动作,一步一步地显示于下图:

image-20220629144350474

transfer所接受的[first,last)区间,是否可以在同一个list之中呢?答案是可以。你只要想象上图所画的两条lists其实是同一个list的两个区段,就不难得到答案了。

上述的 transfer并非公开界面。list公开提供的是所谓的接合动作(splice):将某连续范围的元素从一个list搬移到另一个(或同一个)list的某个定点。如果接续先前 4list-test.cpp 程序的最后执行点,继续执行以下splice动作:

1
2
3
4
5
6
7
int iv[5] = { 5,6,7,8,9 }; 
list<int> ilist2(iv, iv+5);
// 目前,ilist的内容为 0 2 99 3 4
ite = find(ilist.begin(), ilist.end(), 99);
ilist.splice(ite,ilist2); // 0 2 5 6 7 8 9 99 3 4
ilist.reverse(); // 4 3 99 9 8 7 6 5 2 0
ilist.sort();// 0 2 3 4 5 6 7 8 9 99

很容易便可看出效果,接合动作技术上很简单,只是节点间的指标移动而已,这些动作已完全由transfer()做掉了。

image-20220629145208401

为了提供各种接口弹性,list::splice有许多版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public: 
// 将 x接合于 position 所指位置之前。x必须不同于 *this。
void splice(iterator position, list& x) {
if (!x.empty())
transfer(position, x.begin(), x.end());
}
// 将 i 所指元素接合于 position 所指位置之前。position 和 i 可指向同一个 list。
void splice(iterator position, list&, iterator i) {
iterator j = i;
++j;
if (position == i || position == j) return;
transfer(position, i, j);
}
// 将 [first,last) 内的所有元素接合于 position所指位置之前。
// position 和[first,last)可指向同一个 list,
// 但 position 不能位于[first,last)之内。
void splice(iterator position, list&, iterator first, iterator last) {
if (first != last)
transfer(position, first, last);
}

以下是merge(), reverse(), sort()的源码。有了transfer()在手,这些动作都不难完成。

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// merge()将 x合并到 *this身上。两个 lists 的内容都必须先经过递增排序。
template <class T, class Alloc>
void list<T, Alloc>::merge(list<T, Alloc>& x) {
iterator first1 = begin();
iterator last1 = end();
iterator first2 = x.begin();
iterator last2 = x.end();
// 注意:前提是,两个 lists都已经过递增排序,
while (first1 != last1 && first2 != last2) //注:如同归并排序的合并部分
if (*first2 < *first1) {
iterator next = first2;
transfer(first1, first2, ++next);
first2 = next;
}
else
++first1;
if (first2 != last2) transfer(last1, first2, last2);
}

// reverse()将 *this的内容逆向重置
template <class T, class Alloc>
void list<T, Alloc>::reverse() {
// 以下判断,如果是空白串行,或仅有一个元素,就不做任何动作。
// 使用 size() == 0 || size() == 1来判断,虽然也可以,但是比较慢。
if (node->next == node || link_type(node->next)->next == node)
return;
iterator first = begin();
++first;
while (first != end()) {
iterator old = first;
++first;
transfer(begin(), old, first); //左闭右开,把第2个插到最前面,把第3个插到最前面...一直下去就倒序了
}
}

// list 不能使用 STL 算法 sort(),必须使用自己的 sort() member function,
//因为 STL 算法 sort() 只接受 RamdonAccessIterator.
//本函式采用 quick sort.
template <class T, class Alloc>
void list<T, Alloc>::sort() {
// 以下判断,如果是空白串行,或仅有一个元素,就不做任何动作。
// 使用 size() == 0 || size() == 1来判断,虽然也可以,但是比较慢。
if (node->next == node || link_type(node->next)->next == node)
return;
// 一些新的 lists,做为中介数据存放区
list<T, Alloc> carry;
list<T, Alloc> counter[64];
int fill = 0;
while (!empty()) {
carry.splice(carry.begin(), *this, begin());
int i = 0;
while(i < fill && !counter[i].empty()) {
counter[i].merge(carry);
carry.swap(counter[i++]);
}
carry.swap(counter[i]);
if (i == fill) ++fill;
}
for (int i = 1; i < fill; ++i)
counter[i].merge(counter[i-1]);
swap(counter[fill-1]);
}

vector和list对比总结

vector类似数组(不过是动态的)使用连续空间、普通指针,迭代器是随机访问类型的;list类似链表,使用分散的空间、节点指针,迭代器是双向串行类型的。vector只针对尾部操作,而list可以针对头部和尾部。

两者在内存配置有很大的差别,vector需要预先配置一块连续空间,有元素需求时再建构元素(这样建构效率高),但连续空间一旦空间不足就需要重新配置。list则是直接重新分配一个节点,包括空间配置和元素建构。

deque

deque 概述

vector是单向开口的连续线性空间,deque则是一种双向开口的连续线性空间。所谓双向开口,意思是可以在头尾两端分别做元素的安插和删除动作。vector当然也可以在头尾两端做动作(从技术观点),但是其头部动作效率奇差,无法被接受。(注:因为是连续空间,头部元素不能往前扩充,vector只能整体后移或整体前移)

deque和vector的最大差异,一在于deque允许于常数时间内对起头端进行元素的安插或移除动作,二在于deque没有所谓容量(capacity)观念,因为它是动态地以分段连续空间组合而成,随时可以增加一段新的空间并链接起来。换句话说,像vector那样「因旧空间不足而重新配置一块更大空间,然后复制元素,再释放旧空间」这样的事情在deque是不会发生的。也因此,deque没有必要提供所谓的空间保留(reserve)功能。

虽然deque也提供Ramdon Access Iterator,但它的迭代器并不是普通指针,其复杂度和vector不可以道里计(稍后看到源码,你便知道),这当然在在影响了各个运算层面。因此,除非必要,我们应尽可能选择使用vector而非deque。 对 deque进行的排序动作,为了最高效率,可将deque先完整复制到一个 vector 身上,将vector排序后(利用 STL sort算法),再复制回 deque。

deque 的中控器

deque是连续空间(至少逻辑看来如此),deque系由一段一段的定量连续空间构成。一旦有必要在deque的前端或尾端增加新空间,便配置一段定量连续空间,串接在整个deque的头端或尾端。deque的最大任务,便是在这些分段的定量连续空间上,维护其整体连续的假象,并提供随机存取的界面。避开了「重新配置、复制、释放」的轮回,代价则是复杂的迭代器架构。

受到分段连续线性空间的字面影响,可能以为deque的实作复杂度和vector相比虽不中亦不远矣,其实不然。主要因为,既曰分段连续线性空间,就必须有中央控制,而为了维护整体连续的假象,数据结构的设计及迭代器前进后退等动作都颇为繁琐。deque的实作码份量远比vector或list都多得多。

deque采用一块所谓的map(注意,不是 STL 的map容器)做为主控。这里所谓map是一小块连续空间,其中每个元素(此处称为一个节点,node)都是指标,指向另一段(较大的)连续线性空间,称为缓冲区。缓冲区才是deque的储存空间主体。SGI STL允许我们指定缓冲区大小,默认值 0表示将使用 512 bytes缓冲区。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <class T, class Alloc = alloc, size_t BufSiz = 0> 
class deque {
public: // Basic types
typedef T value_type;
typedef value_type* pointer;
...
protected: // Internal typedefs
// 元素的指针的指针(pointer of pointer of T)
typedef pointer* map_pointer;
protected: // Data members
map_pointer map; //指向 map,map 是块连续空间,其内的每个元素
// 都是一个指标(称为节点),指向一块缓冲区。
size_type map_size;// map 内可容纳多少指标。
...
};

map其实是一个T**,也就是说它是一个指标,所指之物又是一 个指标,指向型别为T的一块空间

image-20220629151506642

deque 的迭代器

deque是分段连续空间 。维护其「整体连续」假象的任务 ,着落在迭代器的operator++和operator– 两个运算子身上。

让我们思考一下,deque迭代器应该具备什么结构。首先,它必须能够指出分段连续空间(亦即缓冲区)在哪里,其次它必须能够判断自己是否已经处于其所在缓冲区的边缘,如果是,一旦前进或后退时就必须跳跃至下一个或上一个缓冲区。为了能够正确跳跃,deque必须随时掌握管控中心(map)。下面这种实作方式符合需求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
template <class T, class Ref, class Ptr, size_t BufSiz> 
struct __deque_iterator {//未继承 std::iterator
typedef __deque_iterator<T, T&, T*, BufSiz> iterator;
typedef __deque_iterator<T, const T&, const T*, BufSiz> const_iterator;
static size_t buffer_size() {return __deque_buf_size(BufSiz, sizeof(T)); }
// ᳾继承 std::iterator,所以必须自行撰写五个必要的迭代器相应型别(第 3 章)
typedef random_access_iterator_tag iterator_category; // (1)
typedef T value_type; // (2)
typedef Ptr pointer; // (3)
typedef Ref reference; // (4)
typedef size_t size_type;
typedef ptrdiff_t difference_type;// (5)
typedef T** map_pointer;
typedef __deque_iterator self;
// 保持与容器的联结
T* cur;//此迭代器所指之缓冲区中的现行(current)元素
T* first;//此迭代器所指之缓冲区的头
T* last;//此迭代器所指之缓冲区的尾(含备用空间)
map_pointer node;//指向管控中心
...
};

其中用来决定缓冲区大小的函式buffer_size(),呼叫__deque_buf_size(),后者是个全域函式,定义如下:

1
2
3
4
5
6
7
8
//如果 n不为 0,传回 n,表示 buffer size 由使用者自定。
//如果 n为 0,表示 buffer size使用默认值,那么
//如果 sz(元素大小,sizeof(value_type))小于 512,传回 512/sz,
//如果 sz 不小于 512,传回 1。
inline size_t __deque_buf_size(size_t n, size_t sz)
{
return n != 0 ? n : (sz < 512 ? size_t(512 / sz) : size_t(1));
}

deque的中控器、缓冲区、迭代器的相互关系如下图,中控器控制缓冲区,迭代器能控制缓冲区,也能获取中控器的信息。

image-20220629152413832

假设现在我们产生一个deque<int>,并令其缓冲区大小为32,于是每个缓冲区可容纳 32/sizeof(int)=4 个元素。经过某些操作之后,deque 拥有 20 个元素,那么其begin()和end()所传回的两个迭代器应该如下图。这两个迭代器事实上一直保持在deque内,名为start和finish,稍后在deque数据结构中 便可看到)。

image-20220629152706622

20个元素需要 20/8 = 3 个缓冲区,所以map之内运用了三个节点。迭代器 start 内的 cur指标当然指向缓冲区的第一个元素,迭代器 finish内的 cur指标当然指向缓冲区的最后元素(的下一位置)。注意,最后一个缓冲区尚有备用空间。稍后如果有新元素要安插于尾端,可直接拿此备用空间来使用。

下面是deque迭代器的几个关键行为。由于迭代器内对各种指标运算都做了多载化动作,所以各种指标运算如加、减、前进、后退…都不能直观视之。其中最重点的关键就是:一旦行进时遇到缓冲区边缘,要特别当心,视前进或后退而定,可能需要呼叫set_node()跳一个缓冲区:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
void set_node(map_pointer new_node) { 
node = new_node;
first = *new_node;
last = first + difference_type(buffer_size());
}
//以下各个多载化运算子是 __deque_iterator<> 成功运作的关键。
reference operator*() const { return *cur; }
pointer operator->() const { return &(operator*()); }
difference_type operator-(const self& x) const {
return difference_type(buffer_size()) * (node - x.node - 1) + (cur - first) + (x.last - x.cur);
}

self&operator++() {
++cur; //切换至下一个元素。
if (cur == last) { //如果已达所在缓冲区的尾端,
set_node(node + 1);//就切换至下一节点(亦即缓冲区)的第一个元素。
cur = first;
}
return *this;
}

self operator++(int) {//后置式,标准写法
self tmp = *this;
++*this;
return tmp;
}

self&operator--() {
if (cur == first) { //如果已达所在缓冲区的头端,
set_node(node - 1);//就切换至前一节点(亦即缓冲区)的最后一个元素。
cur = last;
}
--cur; //切换至前一个元素。
return *this;
}

self operator--(int) {//后置式,标准写法
self tmp = *this;
--*this;
return tmp;
}

// 以下实现随机存取。迭代器可以直接跳跃 n个距离。
self& operator+=(difference_type n) {
difference_type offset = n + (cur - first);
if (offset >= 0 && offset < difference_type(buffer_size()))
// 标的位置在同一缓冲区内
cur += n;
else {
// 标的位置不在同一缓冲区内
difference_type node_offset =
offset > 0 ? offset / difference_type(buffer_size()) : -difference_type((-offset - 1) / buffer_size()) - 1;
// 切换至正确的节点(亦即缓冲区)
set_node(node + node_offset);
// 切换至正确的元素
cur = first + (offset - node_offset * difference_type(buffer_size()));
}
return *this;
}

self operator+(difference_type n) const {
self tmp = *this; //*this不变
return tmp += n; // 唤起 operator+=
}

self&operator-=(difference_type n) { return *this+= -n; }
// 以上利用 operator+= 来完成 operator-=
// 参考More Effective C++, item22: Consider using op= instead of
// stand-alone op.
self operator-(difference_type n) const {
self tmp = *this;
return tmp -= n; // 唤起 operator-=
}

// 以下实现随机存取。迭代器可以直接跳跃 n个距离。
reference operator[](difference_type n) const { return *(*this + n); }
// 以上唤起 operator*, operator+
bool operator==(const self& x) const { return cur == x.cur; }
bool operator!=(const self& x) const { return !(*this == x); }
bool operator<(const self& x) const {
return (node == x.node) ? (cur < x.cur) : (node < x.node);
}

deque 的数据结构

deque除了维护一个先前说过的指向map的指标外,也维护start, finish两个迭代器,分别指向第一缓冲区的第一个元素和最后缓冲区的最后一个元素(的下一位置)。此外它当然也必须记住目前的map大小。因为一旦map所提供的节点不足,就必须重新配置更大的一块map

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template <class T, class Alloc = alloc, size_t BufSiz = 0> 
class deque {
public: // Basic types
typedef T value_type;
typedef value_type* pointer;
typedef size_t size_type;
public: // Iterators
typedef __deque_iterator<T, T&, T*,BufSiz> iterator;
protected: // Internal typedefs
// 元素的指针的指针(pointer of pointer of T)
typedef pointer* map_pointer;
protected: // Data members
iterator start; //表现第一个节点。
iterator finish; //表现最后一个节点。
map_pointer map; //指向 map,map 是块连续空间 ,其每个元素都是个指针,指向一个节点(缓冲区)。
size_type map_size;// map 内有多少指标。
...
};

有了上述结构,以下数个机能便可轻易完成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public: // Basic accessors 
iterator begin() { return start; }
iterator end() { return finish; }
reference operator[](size_type n) {
return start[difference_type(n)]; //唤起 __deque_iterator<>::operator[]
}
reference front() { return *start; } // 唤起 __deque_iterator<>::operator*
reference back() {
iterator tmp = finish;
--tmp;//唤起 __deque_iterator<>::operator--
return *tmp; //唤起 __deque_iterator<>::operator*
// 以上三行何不改为:return *(finish-1);
// 因为 __deque_iterator<> 没有为 (finish-1) 定义运算子?!
}
// 下行最后有两个 ‘;’,虽奇怪但合乎语法。
size_type size() const { return finish - start;; }
// 以上唤起 iterator::operator- //注:并没有operator-(iterator& x)的版本啊...
size_type max_size() const { return size_type(-1); }
bool empty() const { return finish == start; }

deque 的建构与内存管理 ctor, push_back, push_front

下面是一个测试程序:

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
38
39
40
41
42
43
44
45
46
47
48
49
// filename : 4deque-test.cpp 
#include <deque>
#include <iostream>
#include <algorithm>
using namespace std;
int main()
{
deque<int,alloc,32> ideq(20,9); // 注意,alloc 只适用于 G++
cout << "size=" << ideq.size() << endl; // size=20
// 现在,应该已经建构了一个 deque,有 20 个 int 元素,初值皆为 9。
// 缓冲区大小为 32bytes。
// 为每一个元素设定新值。
for(int i=0; i<ideq.size(); ++i)
ideq[i] = i;
for(int i=0; i<ideq.size(); ++i)
cout << ideq[i] << ' '; // 0 1 2 3 4 5 6...19
cout << endl;
// 在最尾端增加 3 个元素,其值为 0,1,2
for(int i=0;i<3;i++)
ideq.push_back(i);
for(int i=0; i<ideq.size(); ++i)
cout << ideq[i] << ' '; // 0 1 2 3 ... 19 0 1 2
cout << endl;
cout << "size=" << ideq.size() << endl; // size=23
// 在最尾端增加 1 个元素,其值为 3
ideq.push_back(3);
for(int i=0; i<ideq.size(); ++i)
cout << ideq[i] << ' '; // 0 1 2 3 ... 19 0 1 2 3
cout << endl;
cout << "size=" << ideq.size() << endl; // size=24
// 在最前端增加 1 个元素,其值为 99
ideq.push_front(99);
for(int i=0; i<ideq.size(); ++i)
cout << ideq[i] << ' '; // 99 0 1 2 3...19 0 1 2 3
cout << endl;
cout << "size=" << ideq.size() << endl; // size=25
// 在最前端增加 2 个元素,其值分别为 98,97
ideq.push_front(98);
ideq.push_front(97);
for(int i=0; i<ideq.size(); ++i)
cout << ideq[i] << ' '; // 97 98 99 0 1 2 3...19 0 1 2 3
cout << endl;
cout << "size=" << ideq.size() << endl; // size=27
// 搜寻数值为 99 的元素,并打印出来。
deque<int,alloc,32>::iterator itr;
itr =find(ideq.begin(), ideq.end(), 99);
cout << *itr << endl; // 99
cout << *(itr.cur) << endl; // 99
}

程序一开始宣告了一个deque: deque<int,alloc,32> ideq(20,9);

其缓冲区大小为 32 bytes,并令其保留 20 个元素空间,每个元素初值为 9。为了指定deque的第三个 template参数(缓冲区大小),我们必须将前两个参数都指明出来(这是 C++语法规则),因此必须明确指定alloc(第二章)为空间配置器。

deque自行定义了两个专属的空间配置器:

1
2
3
4
5
protected: // Internal typedefs 
// 专属之空间配置器,每次配置一个元素大小
typedef simple_alloc<value_type, Alloc> data_allocator;
// 专属之空间配置器,每次配置一个指针大小
typedef simple_alloc<pointer, Alloc> map_allocator;

并提供有一个constructor如下:

1
2
3
4
deque(int n, const value_type& value):start(), finish(), map(0), map_size(0) 
{
fill_initialize(n, value);
}

其内所呼叫的 fill_initialize()负责产生并安排好deque的结构,并将元素的初值设定妥当:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <class T, class Alloc, size_t BufSize> 
void deque<T, Alloc, BufSize>::fill_initialize(size_type n, const value_type& value) {
create_map_and_nodes(n); // 把 deque 的结构都产生并安排好
map_pointer cur;
__STL_TRY {
// 为每个节点的缓冲区设定初值
for (cur = start.node; cur < finish.node; ++cur)
uninitialized_fill(*cur, *cur + buffer_size(), value);
// 最后一个节点的设定稍有不同(因为尾端可能有备用空间,不必设初值)
uninitialized_fill(finish.first, finish.cur, value);//注意finish.cur指向末尾
}
catch(...) {
...
}
}

其中 create_map_and_nodes()负责产生并安排好deque的结构:

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
template <class T, class Alloc, size_t BufSize> 
void deque<T, Alloc, BufSize>::create_map_and_nodes(size_type num_elements)
{
// 需要节点数=(元素个数/每个缓冲区可容纳的元素个数)+1
// 如果刚好整除,会多配一个节点。
size_type num_nodes = num_elements / buffer_size() + 1;
// 一个 map要管理几个节点。最少 8 个,最多是 “所需节点数加 2”
// (前后各预留一个,扩充时可用)。
map_size = max(initial_map_size(), num_nodes + 2);
map =map_allocator::allocate(map_size);
// 以上配置出一个 “具有 map_size 个节点”的 map。
// 以下令 nstart 和 nfinish 指向 map 所拥有之全部节点的最中央区段。
// 保持在最中央,可使头尾两端的扩充能量一样大。每个节点可对应一个缓冲区。
map_pointer nstart = map + (map_size - num_nodes) / 2;
map_pointer nfinish = nstart + num_nodes - 1;
map_pointer cur;
__STL_TRY {
// 为 map 内的每个现用节点配置缓冲区。所有缓冲区加起来就是 deque 的
// 可用空间(最后一个缓冲区可能留有一些余裕)。
for (cur = nstart; cur <= nfinish; ++cur)
*cur = allocate_node();
}
catch(...) {
// "commit or rollback"语意:若非全部成功,就一个不留。
...
}
// 为 deque 内的两个迭代器 start 和 end设定正确内容。
start.set_node(nstart);
finish.set_node(nfinish);
start.cur = start.first; // first, cur 都是 public
finish.cur = finish.first + num_elements % buffer_size();
// 前面说过,如果刚好整除,会多配一个节点。
// 此时即令 cur 指向这多配的一个节点(所对映之缓冲区)的起头处。
}

接下来范例程序以注标运算子为每个元素重新设值,然后在尾端安插三个新元素:

1
2
3
4
for(int i=0; i<ideq.size(); ++i) 
ideq[i] = i;
for(int i=0;i<3;i++)
ideq.push_back(i);

由于此时最后一个缓冲区仍有 4 个备用元素空间,所以不会引起缓冲区的再配置。

image-20220629200154708

以下是push_back()函式内容:

1
2
3
4
5
6
7
8
9
10
public: // push_* and pop_* 
void push_back(const value_type& t) {
if (finish.cur != finish.last - 1)
// 最后缓冲区尚有一个以上的备用空间,注意是一个以上
construct(finish.cur, t);//直接在备用空间上建构元素
++finish.cur;//调整最后缓冲区的使用状态
}
else // 最后缓冲区已无(或只剩一个)元素备用空间。
push_back_aux(t);
}

现在,如果再新增加一个新元素(3)于尾端,由于尾端只剩一个元素备用空间,于是push_back()呼叫push_back_aux(),先配置一整块新的缓冲区,再设妥新元素内容,然后更改迭代器finish的状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//只有当 finish.cur == finish.last – 1时才会被呼叫。
//也就是说只有当最后一个缓冲区只剩一个备用元素空间时才会被呼叫。
template <class T, class Alloc, size_t BufSize>
void deque<T, Alloc, BufSize>::push_back_aux(const value_type& t) {
value_type t_copy = t;
reserve_map_at_back(); // 若符合某种条件则必须重换一个 map
*(finish.node + 1) = allocate_node();//配置一个新节点(缓冲区)
__STL_TRY {
construct(finish.cur, t_copy); //针对标的元素设值,放在之前缓冲区的最后一个
finish.set_node(finish.node + 1); //改变 finish,令其指向新节点
finish.cur = finish.first; //设定 finish 的状态
}
__STL_UNWIND(deallocate_node(*(finish.node + 1)));
}

现在,deque的状态如下:

image-20220629200856607

在deque的前端安插一个新元素99,push_front()函式动作如下:

1
2
3
4
5
6
7
8
9
public: // push_* and pop_* 
void push_front(const value_type& t) {
if (start.cur != start.first) { //第一缓冲区尚有备用空间
construct(start.cur - 1, t); // 直接在备用空间上建构元素
--start.cur; //调整第一缓冲区的使用状态
}
else // 第一缓冲区已无备用空间
push_front_aux(t);
}

由于目前状态下,第一缓冲区并无备用空间,所以呼叫push_front_aux():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//只有当 start.cur == start.first 时才会被呼叫。
//也就是说只有当第一个缓冲区没有任何备用元素时才会被呼叫。
template <class T, class Alloc, size_t BufSize>
void deque<T, Alloc, BufSize>::push_front_aux(const value_type& t)
{
value_type t_copy = t;
reserve_map_at_front(); // 若符合某种条件则必须重换一个 map
*(start.node - 1) =allocate_node();//配置一个新节点(缓冲区)
__STL_TRY {
start.set_node(start.node - 1); //改变 start,令其指向新节点
start.cur = start.last - 1; //设定 start 的状态
construct(start.cur, t_copy); //针对标的元素设值
}
catch(...) {
// "commit or rollback"语意:若非全部成功,就一个不留。
start.set_node(start.node + 1);
start.cur = start.first;
deallocate_node(*(start.node - 1));
throw;
}
}

插入后如下,注意向前插入是从缓冲区尾部向前插,这样能保持空间逻辑上的连续:

image-20220629201304194

回头看看一个悬而᳾解的问题:什么时候map需要重新整治?这个问题的判断由reserve_map_at_back()和reserve_map_at_front()进行,实际动作则由reallocate_map()执行:

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
38
39
40
41
42
43
44
 void reserve_map_at_back (size_type nodes_to_add = 1) { 
if (nodes_to_add + 1 > map_size - (finish.node - map))
// 如果 map尾端的节点备用空间不足
// 符合以上条件则必须重换一个 map(配置更大的,拷贝原来的,释放原来的)
reallocate_map(nodes_to_add, false);
}

void reserve_map_at_front (size_type nodes_to_add = 1) {
if (nodes_to_add > start.node - map)
// 如果 map前端的节点备用空间不足
// 符合以上条件则必须重换一个 map(配置更大的,拷贝原来的,释放原来的)
reallocate_map(nodes_to_add, true);
}

//注:我要晕倒了
template <class T, class Alloc, size_t BufSize>
void deque<T, Alloc, BufSize>::reallocate_map(size_type nodes_to_add, bool add_at_front) {
size_type old_num_nodes = finish.node - start.node + 1;
size_type new_num_nodes = old_num_nodes + nodes_to_add;
map_pointer new_nstart;
if (map_size > 2 * new_num_nodes) { //注:这里不需要新开一块map,重新分配的原因是可能前面或者后面仍然有很大的空间,只需要把map平移一下即可
new_nstart = map + (map_size - new_num_nodes) / 2 + (add_at_front ? nodes_to_add: 0);
if (new_nstart < start.node) //注:向前向后平移的判断
copy(start.node, finish.node + 1, new_nstart);
else
copy_backward(start.node, finish.node + 1, new_nstart + old_num_nodes);
}
else {
size_type new_map_size = map_size +max(map_size, nodes_to_add) + 2;
// 配置一块空间,准备给新 map 使用。
map_pointer new_map = map_allocator::allocate(new_map_size);
new_nstart = new_map + (new_map_size - new_num_nodes) / 2 + (add_at_front ? nodes_to_add : 0);
// 把原 map内容拷贝过来。
copy(start.node, finish.node + 1, new_nstart);
// 释放原 map
map_allocator::deallocate(map, map_size);
// 设定新 map 的起始地址与大小
map = new_map;
map_size = new_map_size;
}
// 重新设定迭代器 start和 finish
start.set_node(new_nstart);
finish.set_node(new_nstart + old_num_nodes - 1);
}

deque的元素操作 pop_back, pop_front, clear, erase, insert

pop_back() 和pop_front()。所谓pop,是将元素拿掉。无论从deque 的最前端或最尾端取元素,都需考虑在某种条件下,将缓冲区释放掉:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void pop_back() { 
if (finish.cur != finish.first) { //注:cur原来指向下一个空的区域
// 最后缓冲区有一个(或更多)元素
--finish.cur; //调整指针,相当于排除了最后元素
}
destroy(finish.cur);//将最后元素解构
else
// 最后缓冲区没有任何元素
pop_back_aux(); //这里将进行缓冲区的释放工作
}

//只有当 finish.cur == finish.first 时才会被呼叫。
template <class T, class Alloc, size_t BufSize>
void deque<T, Alloc, BufSize>::pop_back_aux() {
deallocate_node(finish.first); //释放最后一个缓冲区
finish.set_node(finish.node - 1);//调整 finish 的状态,使指向
finish.cur = finish.last - 1; // 上一个缓冲区的最后一个元素
destroy(finish.cur); //将该元素解构。
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void pop_front() { 
if (start.cur != start.last - 1) {
// 第一缓冲区有一个(或更多)元素
destroy(start.cur);//将第一元素解构
++start.cur; //调整指针,相当于排除了第一元素
}
else
// 第一缓冲区仅有一个元素
pop_front_aux(); //这里将进行缓冲区的释放工作
}

//只有当 start.cur == start.last - 1 时才会被呼叫。
template <class T, class Alloc, size_t BufSize>
void deque<T, Alloc, BufSize>::pop_front_aux() {
destroy(start.cur); //将第一缓冲区的第一个元素解构。
deallocate_node(start.first); //释放第一缓冲区。
start.set_node(start.node + 1);//调整 start的状态,使指向下一个缓冲区的第一个元素。
start.cur = start.first;
}

下面这个例子是 clear(),用来清除整个 deque。请注意,deque的最初状态(无任何元素时)保有一个缓冲区,因此clear()完成之后回复初始状态,也一样要保留一个缓冲区:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//注意,最终需要保留一个缓冲区。这是 deque的策略,也是 deque的初始状态。
template <class T, class Alloc, size_t BufSize>
void deque<T, Alloc, BufSize>::clear() {
// 以下针对头尾以外的每一个缓冲区(它们一定都是饱满的)
for (map_pointer node = start.node + 1; node < finish.node; ++node) {
// 将缓冲区内的所有元素解构。注意,呼叫的是 destroy() 第二版本
destroy(*node, *node + buffer_size());
// 释放缓冲区内存
data_allocator::deallocate(*node, buffer_size());
}
if (start.node != finish.node) {//至少有头尾两个缓冲区
destroy(start.cur, start.last);//将头缓冲区的目前所有元素解构
destroy(finish.first, finish.cur); // 将尾缓冲区的目前所有元素解构
// 以下释放尾缓冲区。注意,头缓冲区保留。
data_allocator::deallocate(finish.first, buffer_size());
}
else//只有一个缓冲区
destroy(start.cur, finish.cur);//将此唯一缓冲区内的所有元素解构
// 注意,并不释放缓冲区空间。这唯一的缓冲区将保留。
finish = start; //调整状态
}

下面这个例子是 erase(),用来清除某个元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 清除 pos所指的元素。pos为清除点。
iterator erase(iterator pos) { //注:实际上是对pos.cur清除(因为调用*pos)
iterator next = pos;
++next; //注:pos.cur.next
difference_type index = pos - start;//清除点之前的元素个数
if (index < (size() >> 1)) { //如果清除点之前的元素比较少,
copy_backward(start, pos, next);//就搬移清除点之前的元素
}
pop_front(); //搬移完毕,最前一个元素赘余,去除之
else { //清除点之后的元素比较少,
copy(next, finish, pos);//就搬移清除点之后的元素
}
pop_back(); //搬移完毕,最后一个元素赘余,去除之
return start + index;
}

下面这个例子是 erase(),用来清除[first,last)区间内的所有元素:

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
template <class T, class Alloc, size_t BufSize> 
deque<T, Alloc, BufSize>::iterator
deque<T, Alloc, BufSize>::erase(iterator first, iterator last) {
if (first == start && last == finish) {// 如果清除区间就是整个 deque
clear(); //直接呼叫 clear()即可
return finish;
}
else {
difference_type n = last - first; //清除区间的长度
difference_type elems_before = first - start;//清除区间前方的元素个数

//注:这里的搬运是因为清除一个区间可能截断了连续的数据空间,因此要把可能产生的前后两端连接起来
if (elems_before < (size() - n) / 2) { //如果前方的元素比较少,
copy_backward(start, first, last); //向后搬移前方元素(覆盖清除区间)
iterator new_start = start + n; //标记 deque的新起点
destroy(start, new_start); //搬移完毕,将赘余的元素解构
// 以下将赘余的缓冲区释放
for (map_pointer cur = start.node; cur < new_start.node; ++cur)
data_allocator::deallocate(*cur, buffer_size());
start = new_start;//设定 deque 的新起点
}
else {//如果清除区间后方的元素比较少
copy(last, finish, first); //向前搬移后方元素(覆盖清除区间)
iterator new_finish = finish - n;//标记 deque的新尾点
destroy(new_finish, finish); //搬移完毕,将赘余的元素解构
// 以下将赘余的缓冲区释放
for (map_pointer cur = new_finish.node + 1; cur <= finish.node; ++cur)
data_allocator::deallocate(*cur, buffer_size());
finish = new_finish;//设定 deque 的新尾点
}
return start + elems_before;
}
}

最后一个例子是insert。deque为这个功能提供了许多版本,最基础最重要的是以下版本,允许在某个点(之前)安插一个元素,并设定其值。

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
38
39
40
41
42
43
44
45
46
// 在 position 处安插一个元素,其值为 x 
iterator insert(iterator position, const value_type& x) {
if (position.cur == start.cur) {//如果安插点是 deque最前端
push_front(x); //交给 push_front 去做
return start;
}
else if (position.cur == finish.cur) { // 如果安插点是 deque最尾端
push_back(x); //交给 push_back去做
iterator tmp = finish;
--tmp;
return tmp;
}
else {
return insert_aux(position, x); //交给 insert_aux 去做
}
}

template <class T, class Alloc, size_t BufSize>
typename deque<T, Alloc, BufSize>::iterator
deque<T, Alloc, BufSize>::insert_aux(iterator pos, const value_type& x) {
difference_type index = pos - start;//安插点之前的元素个数
value_type x_copy = x;
if (index < size() / 2) { //如果安插点之前的元素个数比较少
push_front(front()); //在最前端加入与第一元素同值的元素。注:相当于把安插点前面的元素向前移一格
iterator front1 = start; //以下标示记号,然后进行元素搬移...
++front1;
iterator front2 = front1;
++front2;
pos = start + index;
iterator pos1 = pos;
++pos1;
copy(front2, pos1, front1); //元素搬移,注:把原来的头元素覆盖,这样position位置就空出来了
}
else { //安插点之后的元素个数比较少
push_back(back()); //在最尾端加入与最后元素同值的元素。注:相当于把安插点后面的元素向后移一格
iterator back1 = finish;//以下标示记号,然后进行元素搬移...
--back1;
iterator back2 = back1;
--back2;
pos = start + index;
copy_backward(pos, back2, back1); //元素搬移,注:把原来的尾元素覆盖,这样position位置就空出来了
}

*pos = x_copy;//在安插点上设定新值
return pos;
}

总结

deque相比vector和list要复杂很多,是一个二级结构。第一级是map,一块连续的空间,其中每个node又指向第二级的一块连续的空间(缓冲区),这些连续空间可以不连续(但逻辑上连续)。为了维护这个逻辑连续,迭代器(指向map的node,也指向缓冲区的节点)的设计就变得十分复杂。同时map中的节点是从中间向两边扩展,这就使得从头部插入、删除节点与从尾部插入、删除节点的动作是对称的,使得相比vector来说,头部操作的效率变好了。当空间不足时,一定是map除了问题,可以整体前移、后移,或是重新分配一个更大的空间。

为了兼容算法的接口,迭代器所“代表”的元素就是迭代器的cur节点指向的元素(重载了的*、++等运算,都是针对cur节点)。

stack

stack 概述

stack是一种先进后出(First In Last Out,FILO)的数据结构。stack允许新增元素、移除元素、取得最顶端元素。但除了最顶端外,没有任何其它方法可以存取stack的其它元素。换言之stack不允许有走访行为。

将元素推入 stack 的动作称为 push,将元素推出 stack 的动作称为pop

stack 定义式完整列表

以某种既有容器做为底部结构,将其接口改变,使符合「先进后出」的特性,形成一个stack,是很容易做到的。deque是双向开口的数据结构,若以deque为底部结构并封闭其头端开口,便轻而易举地形成了一个stack。因此,SGI STL 便 以deque做为预设情况下的stack底部结构,stack的实作因而非常简单,源码十分简短,本处完整列出。

由于stack系以底部容器完成其所有工作,而具有这种「修改某物接口,形成另一种风貌」之性质者,称为adapter(配接器),因此 STL stack往往不被归类为 container(容器),而被归类为container adapter

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
template <class T, class Sequence = deque<T> > 
class stack {
// 以下的 __STL_NULL_TMPL_ARGS 会开展为 <>
friend bool operator== __STL_NULL_TMPL_ARGS (const stack&, const stack&);
friend bool operator< __STL_NULL_TMPL_ARGS (const stack&, const stack&);
public:
typedef typename Sequence::value_type value_type;
typedef typename Sequence::size_type size_type;
typedef typename Sequence::reference reference;
typedef typename Sequence::const_reference const_reference;
protected:
Sequence c;
public:
//底层容器
// 以下完全利用 Sequence c 的操作,完成 stack的操作。
bool empty() const { return c.empty(); }
size_type size() const { return c.size(); }
reference top() { return c.back(); }
const_reference top() const { return c.back(); }
// deque是两头可进出,stack是末端进,末端出(所以后进者先出)。
void push(const value_type& x) { c.push_back(x); }
void pop() { c.pop_back(); }
};

template <class T, class Sequence>
bool operator==(const stack<T, Sequence>& x, const stack<T, Sequence>& y)
{
return x.c == y.c;
}

template <class T, class Sequence>
bool operator<(const stack<T, Sequence>& x, const stack<T, Sequence>& y)
{
return x.c < y.c;
}

stack 没有迭代器

stack 所有元素的进出都必须符合「先进后出」的条件,只有 stack 顶端的元素,才有机会被外界取用。stack不提供走访功能,也不提供迭代器。

以 list做为 stack 的底层容器

除了deque之外,list 也是双向开口的数据结构。上述 stack 源码中使用的底层容器的函式有empty, size, back, push_back, pop_back,凡此种种 list 都具备。因此若以list为底部结构并封闭其头端开口,一样能够轻易形成一个stack。下面是作法示范。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// file : 4stack-test.cpp 
#include <stack>
#include <list>
#include <iostream>
#include <algorithm>
using namespace std;
int main()
{
stack<int,list<int> > istack;
istack.push(1);
istack.push(3);
istack.push(5);
istack.push(7);
cout << istack.size() << endl; // 4
cout << istack.top() << endl; // 7
istack.pop(); cout << istack.top() << endl; // 5
istack.pop(); cout << istack.top() << endl; // 3
istack.pop(); cout << istack.top() << endl; // 1
cout << istack.size() << endl; // 1
}

queue

queue 概述

queue是一种先进先出(First In First Out,FIFO)的数据结构。它有两个出口。queue允许新增元素、移除元素、从最底端加入元素、取得最顶端元素。但除了最底端可以加入、最顶端可以取出,没有任何其它方法可以存取queue 的其它元素。换言之 queue 不允许有走访行为。

将元素推入 queue 的动作称为 push,将元素推出 queue 的动作称为pop

queue 定义式完整列表

SGI STL 依然以deque做为预设情况下的queue底部结构,因此queue也被归类为container adapter。

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
template <class T, class Sequence = deque<T> > 
class queue {
// 以下的 __STL_NULL_TMPL_ARGS 会开展为 <>
friend bool operator== __STL_NULL_TMPL_ARGS (const queue& x, const queue& y);
friend bool operator< __STL_NULL_TMPL_ARGS (const queue& x, const queue& y);
public:
typedef typename Sequence::value_type value_type;
typedef typename Sequence::size_type size_type;
typedef typename Sequence::reference reference;
typedef typename Sequence::const_reference const_reference;
protected:
Sequence c;
public:
//底层容器
// 以下完全利用 Sequence c 的操作,完成 queue的操作。
bool empty() const { return c.empty(); }
size_type size() const { return c.size(); }
reference front() { return c.front(); }
const_reference front() const { return c.front(); }
reference back() { return c.back(); }
const_reference back() const { return c.back(); }
// deque是两头可进出,queue 是末端进,前端出(所以先进者先出)。
void push(const value_type& x) { c.push_back(x); }
void pop() { c.pop_front(); }
};
template <class T, class Sequence>
bool operator==(const queue<T, Sequence>& x, const queue<T, Sequence>& y)
{
return x.c == y.c;
}

template <class T, class Sequence>
bool operator<(const queue<T, Sequence>& x, const queue<T, Sequence>& y)
{
return x.c < y.c;
}

queue 没有迭代器

queue 所有元素的进出都必须符合「先进先出」的条件,只有 queue 顶端的元素,才有机会被外界取用。queue不提供走访功能,也不提供迭代器。

以 list做为queue 的底层容器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// file : 4queue-test.cpp 
#include <queue>
#include <list>
#include <iostream>
#include <algorithm>
using namespace std;
int main()
{
queue<int,list<int> > iqueue;
iqueue.push(1);
iqueue.push(3);
iqueue.push(5);
iqueue.push(7);
cout << iqueue.size() << endl; // 4
cout << iqueue.front() << endl; // 1
iqueue.pop(); cout << iqueue.front() << endl; // 3
iqueue.pop(); cout << iqueue.front() << endl; // 5
iqueue.pop(); cout << iqueue.front() << endl; // 7
cout << iqueue.size() << endl; // 1
}

heap

heap 概述

heap并不归属于STL 容器组件,它是个幕后英雄,扮演priority queue (优先级队列)的推手。顾名思义,priority queue 允许使用者以任何次序将任何元素推入容器内,但取出时一定是从优先权最高(也就是数值最高)之元素开始取。binary max heap正是具有这样的特性,适合做为 priority queue的底层机制。

为何使用堆作为优先级队列的底层

如果使用 list 做为 priority queue 的底层机制,元素安插动作可享常数时间。但是要找到list中的极值,却需要对整个list进行线性扫描。我们也可以改个作法,让元素安插前先经过排序这一关,使得 list 的元素值总是由小到大(或由大到小),但这么一来,虽然取得极值以及元素删除动作达到最高效率,元素的安插却只有线性表现。

比较麻辣的作法是以binary search tree 做为priority queue的底层机制。这么一来元素的安插和极值的取得就有*O(logN)*的表现。但这样未免小题大作,一来binary search tree的输入需要足够的随机性, 二来binary search tree并不容易实作。priority queue的复杂度,最好介于queue和binary search tree之间,才算适得其所。binary heap便是这种条件下 的适当候选人。

堆的一些细节

所谓binary heap就是一种complete binary tree(完全二叉树),也就是说,整棵binary tree除了最底层的叶节点(s)之外,是填满的,而最底层的叶节点(s)由左至右又不得有空隙。

complete binary tree整棵树内没有任何节点漏洞,这带来一个极大好处:我们可以利用array来储存所有节点。假设动用一个小技巧,将array的**#0元素保留(或设为无限大值或无限小值),那么当complete binary tree中的某个节点位于array的 i 处,其左子节点必位于array 的 2i 处,其右子节点必位于 array的 2i+1 处,其父节点必位于「i/2**」处(此处的「」权且代表高斯符号,取其整数)。通过这么简单的位置规则,array可以轻易实作出complete binary tree。这种以array表述tree的方式,我们称为隐式表述法(implicit representation)。

我们需要的工具就很简单了:一个array和一组heap算法(用来安插元素、删除元素、取极值、将某一整组数据排列成一个heap)。array的缺点是无法动态改变大小,而heap却需要这项功能,因此以vector代替 array是更好的选择。

根据元素排列方式,heap可分为max-heapmin-heap (最大堆和最小堆)两种,前者每个节点的键值(key)都大于或等于其子节点键值,后者的每个节点键值(key)都小于或等于其子节点键值。因此,max-heap的最大值在根节点,并总是位于底层 array 或vector的起头处;min-heap的最小值在根节点,亦总是位于底层array或vector的起头处。STL 供应的是max-heap,因此以下说heap时,指的是max-heap。

heap 算法

push_heap 算法

插入元素首先插入到最后端,然后向上过滤。为满足 max-heap 的条件(每个节点的键值都大于或等于其子节点键值),我们执行一个所谓的percolate up(上溯)程序:将新节点拿来与其父节点比较,如果其键值(key)比父节点大,就父子对换位置。如此一直上溯,直到不需对换或直到根节点为止。

image-20220707145259201

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
template <class RandomAccessIterator> 
inline void push_heap(RandomAccessIterator first, RandomAccessIterator last) {
// 注意,此函式被呼叫时,新元素应已置于底部容器的最尾端。
__push_heap_aux(first, last, distance_type(first), value_type(first));
}
template <class RandomAccessIterator, class Distance, class T>
inline void __push_heap_aux(RandomAccessIterator first, RandomAccessIterator last, Distance*, T*) {
__push_heap(first, Distance((last - first) - 1), Distance(0), T(*(last - 1)));
// 以上系根据 implicit representation heap的结构特性:新值必置于底部
// 容器的最尾端,此即第一个洞号:(last-first)–1。
}
//以下这组 push_back()不允许指定「大小比较标准」
template <class RandomAccessIterator, class Distance, class T>
void__push_heap(RandomAccessIterator first, Distance holeIndex, Distance topIndex, T value) {
Distance parent = (holeIndex - 1) / 2;//找出父节点
while (holeIndex > topIndex && *(first + parent)< value) {
// 当尚未到达顶端,且父节点小于新值(于是不符合 heap 的次序特性)
// 由于以上使用 operator<,可知 STL heap 是一种 max-heap(大者为父)。
*(first + holeIndex) = *(first + parent);//令洞值为父值
holeIndex = parent;//percolate up:调整洞号,向上提升至父节点。
parent = (holeIndex - 1) / 2;//新洞的父节点
} // 持续至顶端,或满足 heap 的次序特性为止。
*(first + holeIndex) = value;//令洞值为新值,完成安插动作。
}

pop_heap 算法

既然身为max-heap,最大值必然在根节点。pop动作取走根节点(其实是移至底部容器vector的最后一个元素)之后,为了满足 complete binary tree的条件,必须将最下一层最右边的叶节点拿掉,现在我们的任务是为这个被拿掉的节点找一个适当的位置。

为满足max-heap的条件(每个节点的键值都大于或等于其子节点键值),我们执行一个所谓的percolate down(向下过滤)程序:将根节点(最大值被取走后,形成一个「洞」)填入上述那个失去生存空间的叶节点值,再将它拿来和其两个子节点比较键值(key),并与较大子节点对调位置。如此一直下放,直到这个「洞」的键值大于左右两个子节点,或直到下放至叶节点为止。

image-20220707145736213

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
38
39
40
41
42
43
44
template <class RandomAccessIterator> 
inline void pop_heap(RandomAccessIterator first, RandomAccessIterator last) {
__pop_heap_aux(first, last, value_type(first));
}
template <class RandomAccessIterator, class T>
inline void __pop_heap_aux(RandomAccessIterator first, RandomAccessIterator last, T*) {
__pop_heap(first, last-1, T(*(last-1)), distance_type(first));
// 以上,根据 implicit representation heap的次序特性,pop 动作的结果
// 应为底部容器的第一个元素。因此,首先设定欲调整值为尾值,然后将首值调至
// 尾节点(所以以上将迭代器 result 设为 last-1)。然后重整 [first, last-1),
// 使之重新成一个合格的 heap。
}
//以下这组 __pop_heap() 不允许指定「大小比较标准」
template <class RandomAccessIterator, class T, class Distance>
inline void __pop_heap(RandomAccessIterator first, RandomAccessIterator last, RandomAccessIterator result, T value, Distance*) {
*result = *first;// 设定尾值为首值,于是尾值即为欲求结果,
// 可由客端稍后再以底层容器之 pop_back() 取出尾值。
__adjust_heap(first, Distance(0), Distance(last - first), value);
// 以上欲重新调整 heap,洞号为 0(亦即树根处),欲调整值为 value(原尾值)。
}
//以下这个 __adjust_heap()不允许指定「大小比较标准」
template <class RandomAccessIterator, class Distance, class T>
void__adjust_heap(RandomAccessIterator first, Distance holeIndex, Distance len, T value) {
Distance topIndex = holeIndex;
Distance secondChild = 2 * holeIndex + 2;//洞节点之右子节点
while (secondChild < len) {
// 比较洞节点之左右两个子值,然后以 secondChild代表较大子节点。
if (*(first + secondChild) < *(first + (secondChild - 1)))
secondChild--;
// Percolate down:令较大子值为洞值,再令洞号下移至较大子节点处。
*(first + holeIndex) = *(first + secondChild);
holeIndex = secondChild;
// 找出新洞节点的右子节点
secondChild = 2 * (secondChild + 1);
}
if (secondChild == len) {//没有右子节点,只有左子节点
// Percolate down:令左子值为洞值,再令洞号下移至左子节点处。
*(first + holeIndex) = *(first + (secondChild - 1));
holeIndex = secondChild - 1;
}
// 将欲调整值填入目前的洞号内。注意,此时肯定满足次序特性。
// 依侯捷之见,下面直接改为 *(first + holeIndex) = value; 应该可以。
__push_heap(first, holeIndex, topIndex, value); //注:就是最后把原来last元素填好位置
}

sort_heap 算法

既然每次pop_heap可获得 heap之中键值最大的元素,如果持续对整个 heap做pop_heap动作,每次将操作范围从后向前缩减一个元素(因为 pop_heap 会把键值最大的元素放在底部容器的最尾端),当整个程序执行完毕,我们便有了一个递增序列。

1
2
3
4
5
6
7
8
9
//以下这个 sort_heap()不允许指定「大小比较标准」
template <class RandomAccessIterator>
void sort_heap(RandomAccessIterator first, RandomAccessIterator last) {
// 以下,每执行一次 pop_heap(),极值(在 STL heap 中为极大值)即被放在尾端。
// 扣除尾端再执行一次 pop_heap(),次极值又被放在新尾端。一直下去,最后即得
// 排序结果。
while (last - first > 1)
pop_heap(first, last--);//每执行 pop_heap() 一次,操作范围即退缩一格。
}

make_heap 算法

这个算法用来将一段现有的数据转化为一个 heap。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//将 [first,last) 排列为一个 heap。
template <class RandomAccessIterator>
inline void make_heap(RandomAccessIterator first, RandomAccessIterator last) {
__make_heap(first, last,value_type(first), distance_type(first));
}
//以下这组 make_heap()不允许指定「大小比较标准」。
template <class RandomAccessIterator, class T, class Distance>
void __make_heap(RandomAccessIterator first, RandomAccessIterator last, T*, Distance*) {
if (last - first < 2) return;//如果长度为 0或 1,不必重新排列。
Distance len = last - first;
// 找出第一个需要重排的子树头部,以 parent 标示出。由于任何叶节点都不需执行
// perlocate down,所以有以下计算。parent命名不佳,名为 holeIndex更好。
Distance parent = (len - 2)/2;
while (true) {
// 重排以 parent 为首的子树。len 是为了让 __adjust_heap()判断操作范围
__adjust_heap(first, parent, len, T(*(first + parent)));
if (parent == 0) return;//走完根节点,就结束。
parent--; //(即将重排之子树的)头部向前一个节点
}
}

heap 没有迭代器

heap 的所有元素都必须遵循特别的(complete binary tree)排列规则,所以 heap不提供走访功能,也不提供迭代器。

heap测试实例

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
38
39
40
41
42
43
44
45
46
47
#include <vector> 
#include <iostream>
#include <algorithm> // heap algorithms
using namespace std;
int main()
{
{
// test heap (底层以 vector完成)
int ia[9] = {0,1,2,3,4,8,9,3,5};
vector<int> ivec(ia, ia+9);
make_heap(ivec.begin(), ivec.end());
for(int i=0; i<ivec.size(); ++i)
cout << ivec[i] << ' '; // 9 5 8 3 4 0 2 3 1
cout << endl;
ivec.push_back(7);
push_heap(ivec.begin(), ivec.end());
for(int i=0; i<ivec.size(); ++i)
cout << ivec[i] << ' '; // 9 7 8 3 5 0 2 3 1 4
cout << endl;
pop_heap(ivec.begin(), ivec.end());
cout << ivec.back() << endl; // 9.return but no remove.
ivec.pop_back(); // remove last elem and no return
for(int i=0; i<ivec.size(); ++i)
cout << ivec[i] << ' '; // 8 7 4 3 5 0 2 3 1
cout << endl;
sort_heap(ivec.begin(), ivec.end());
for(int i=0; i<ivec.size(); ++i)
cout << ivec[i] << ' '; // 0 1 2 3 3 4 5 7 8
cout << endl;
}

{
// test heap (底层以 array 完成)
int ia[9] = {0,1,2,3,4,8,9,3,5};
make_heap(ia, ia+9);
// array 无法动态改变大小,因此不可以对满载的 array 做 push_heap() 动作。
// 因为那得先在 array尾端增加一个元素。
sort_heap(ia, ia+9);
for(int i=0; i<9; ++i)
cout << ia[i] << ' '; // 0 1 2 3 3 4 5 8 9
cout << endl;
// 经过排序之后的 heap,不再是个合法的 heap
// 重新再做一个 heap
make_heap(ia, ia+9);
pop_heap(ia, ia+9);
cout << ia[8] << endl; // 9
}

priority_queue

priority_queue 概述

priority_queue是一个拥有权值观念的 queue,它允许加入新元素、移除旧元素,审视元素值等功能。由于这是一个queue,所以只允许在底端加入元素,并从顶端取出元素,除此之外别无其它存取元素的途径。

priority_queue带有权值观念,其内的元素并非依照被推入的次序排列,而是自动依照元素的权值排列(通常权值以实值表示)。权值最高者,排在最前面

预设情况下priority_queue系利用一个max-heap 完成,后者是一个以 vector 表现的 complete binary tree。max-heap 可以满足priority_queue所需要的「依权值高低自动递增排序」的特性。

priority_queue 定义式完整列表

由于priority_queue完全以底部容器为根据,再加上heap处理规则,所以其实作非常简单。预设情况下是以vector为底部容器。源码很简短,此处完整列出。

STL priority_queue 也不被归类为container(容器),而被归类为container adapter。

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
38
39
40
41
template <class T, class Sequence = vector<T>, class Compare = less<typename Sequence::value_type> > class priority_queue { 
public:
typedef typename Sequence::value_type value_type;
typedef typename Sequence::size_type size_type;
typedef typename Sequence::reference reference;
typedef typename Sequence::const_reference const_reference;
protected:
Sequence c; //底层容器
Compare comp;//元素大小比较标准
public:
priority_queue() : c() {}
explicit priority_queue(const Compare& x) : c(), comp(x) {}
//以下用到的 make_heap(), push_heap(), pop_heap()都是泛型算法
//注意,任一个建构式都立刻于底层容器内产生一个 implicit representation heap。
template <class InputIterator>
priority_queue(InputIterator first, InputIterator last, const Compare& x) : c(first, last), comp(x) {make_heap(c.begin(), c.end(), comp); }

template <class InputIterator>
priority_queue(InputIterator first, InputIterator last) : c(first, last) { make_heap(c.begin(), c.end(), comp); }

bool empty() const { return c.empty(); }
size_type size() const { return c.size(); }
const_reference top() const { return c.front(); }
void push(const value_type& x) {
__STL_TRY {
// push_heap是泛型算法,先利用底层容器的 push_back() 将新元素
c.push_back(x); // 推入末端,再重排 heap。
push_heap(c.begin(), c.end(), comp);// push_heap是泛型算法
}
__STL_UNWIND(c.clear());
}
void pop() {
__STL_TRY {
// pop_heap 是泛型算法,从 heap 内取出一个元素。它并不是真正将元素
// 弹出,而是重排 heap,(首值被放在尾部)然后再以底层容器的 pop_back() 取得被弹出的元素
pop_heap(c.begin(), c.end(), comp);
c.pop_back();
}
__STL_UNWIND(c.clear());
}
};

priority_queue 没有迭代器

priority_queue 的所有元素,进出都有一定的规则,只有 queue 顶端的元素(权值最高者),才有机会被外界取用。priority_queue不提供走访功能,也不提供迭代器。

priority_queue 测试实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <queue> 
#include <iostream>
#include <algorithm>
using namespace std;
int main()
{
// test priority queue...
int ia[9] = {0,1,2,3,4,8,9,3,5};
priority_queue<int> ipq(ia, ia+9);
cout << "size=" << ipq.size() << endl; // size=9
for(int i=0; i<ipq.size(); ++i)
cout << ipq.top() << ' '; // 9 9 9 9 9 9 9 9 9
cout << endl;
while(!ipq.empty()) {
cout << ipq.top() << ' '; // 9 8 5 4 3 3 2 1 0
ipq.pop();
}
cout << endl;
}

总结

细致观察的话,我们会发现heap只提供了算法,而没有实际上作出容器。这与前面stack有不同:stack以deque为底层容器,封装deque的操作控制deque的元素。而heap没有元素,原因是优先级队列真正的底层容器是vector,所谓“heap”在这里只是概念,用来提供算法操作。为什么需要用vector而不是一个普通的array呢?因为优先级队列是可以动态扩展的,只有vector符合动态的理念,并且其迭代器是普通指针,满足随机访问的要求。而vector的元素删除、插入都是在尾部进行的,这也是为什么heap的pop算法把元素放到尾部就停止了(没有继续解构元素),就是为了能交给vector来pop。

最后,我们会发现所有容器的pop,均不返回值,原因是元素已真正解构了。

slist

slist 概述

STL list是个双向串行(double linked list)。SGI STL 另提供了一个单向串行(single linked list),名为slist。这个容器并不在标准规格之内,不过多做一 些剖析,多看多学一些实作技巧也不错。

slist和list的主要差别在于,前者的迭代器属于单向的 Forward Iterator,后者的迭代器属于双向的Bidirectional Iterator。为此,slist的功能自然也就受到许多限制。不过,单向串行所耗用的空间更小某些动作更快,不失为另一种选择。

根据STL的习惯,安插动作会将新元素安插于指定位置之前,而非之后。然而做为一个单向串行,slist没有任何方便的办法可以回头定出前一个位置,因此它必须从头找起。换句话说,除了slist起始处附近的区域之外,在其它位置上采用insert 或erase 操作函式,都是不智之举。这便是 slist 相较于 list 之下的大缺点。为此,slist特别提供了 insert_after()和erase_after()供弹性运用。

基于同样的(效率)考虑,slist不提供 push_back(),只提供 push_front()。因此 slist的元素次序会和元素安插进来的次序相反

slist 的节点

slist节点和其迭代器的设计,架构上比list复杂许多,运用了继承关系,因此在型别转换上有复杂的表现。

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
//单向串行的节点基本结构。注:只操作指针
struct __slist_node_base
{
__slist_node_base* next;
};
//单向串行的节点结构
template <class T>
struct __slist_node : public __slist_node_base
{
T data;
};
//全域函式:已知某一节点,安插新节点于其后。
inline __slist_node_base* __slist_make_link( __slist_node_base* prev_node, __slist_node_base* new_node)
{
// 令 new节点的下一节点为 prev节点的下一节点
new_node->next = prev_node->next;
prev_node->next = new_node;//令 prev 节点的下一节点指向 new 节点
return new_node;
}
//全域函式:单向串行的大小(元素个数)
inline size_t __slist_size(__slist_node_base* node)
{
size_t result = 0;
for ( ; node != 0; node = node->next)
++result; //一个一个累计
return result;
}

image-20220707210631932

slist 的迭代器

slist 迭代器可以下图表示:

image-20220707210714470

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
38
39
40
41
42
43
44
45
46
47
//单向串行的迭代器基本结构
struct __slist_iterator_base
{
typedef size_t size_type;
typedef ptrdiff_t difference_type;
typedef forward_iterator_tag iterator_category;//注意,单向
__slist_node_base* node;//指向节点基本结构
__slist_iterator_base(__slist_node_base* x) :node(x) {}
void incr() { node = node->next; }// 前进一个节点
bool operator==(const __slist_iterator_base& x) const {
return node == x.node;
}
bool operator!=(const __slist_iterator_base& x) const {
return node != x.node;
}
};

//单向串行的迭代器结构
template <class T, class Ref, class Ptr>
struct __slist_iterator : public __slist_iterator_base
{
typedef __slist_iterator<T, T&, T*> iterator;
typedef __slist_iterator<T, const T&, const T*> const_iterator;
typedef __slist_iterator<T, Ref, Ptr> self;
typedef T value_type;
typedef Ptr pointer;
typedef Ref reference;
typedef __slist_node<T> list_node;
__slist_iterator(list_node* x) : __slist_iterator_base(x) {}
// 呼叫 slist<T>::end() 时会造成 __slist_iterator(0),于是唤起上述函式。
__slist_iterator() : __slist_iterator_base(0) {}
__slist_iterator(const iterator&x) : __slist_iterator_base(x.node) {}
reference operator*() const { return ((list_node*) node)->data; }
pointer operator->() const { return &(operator*()); }
self& operator++()
{
incr();//前进一个节点
return *this;
}
self operator++(int)
{
self tmp = *this;
incr();//前进一个节点
return tmp;
}
//没有实作 operator--,因为这是一个 forward iterator
};

slist 的数据结构

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
template <class T, class Alloc = alloc> 
class slist
{
public:
typedef T value_type;
typedef value_type* pointer;
typedef const value_type* const_pointer;
typedef value_type& reference;
typedef const value_type& const_reference;
typedef size_t size_type;
typedef ptrdiff_t difference_type;
typedef __slist_iterator<T, T&, T*> iterator;
typedef __slist_iterator<T, const T&, const T*> const_iterator;
private:
typedef __slist_node<T> list_node;
typedef __slist_node_base list_node_base;
typedef __slist_iterator_base iterator_base;
typedef simple_alloc<list_node, Alloc>list_node_allocator;
static list_node* create_node(const value_type& x) {
list_node* node =list_node_allocator::allocate();//配置空间
__STL_TRY {
construct(&node->data, x); //建构元素
node->next = 0;
}
__STL_UNWIND(list_node_allocator::deallocate(node));
return node;
}
static void destroy_node(list_node* node) {
destroy(&node->data); //将元素解构
list_node_allocator::deallocate(node); //释还空间
}

private:
list_node_base head; // 头部。注意,它不是指标,是实物。
public:
slist() { head.next = 0; }
~slist() { clear(); }
public:
iterator begin() { return iterator((list_node*)head.next); }
iterator end() { return iterator(0); }
size_type size() const { return __slist_size(head.next); }
bool empty() const { return head.next == 0; }
// 两个 slist互换:只要将 head 交换互指即可。
void swap(slist& L)
{
list_node_base* tmp = head.next;
head.next = L.head.next;
L.head.next = tmp;
}
public:
// 取头部元素
reference front() { return ((list_node*)head.next)->data; }
// 从头部安插元素(新元素成为 slist 的第一个元素)
void push_front(const value_type& x) {
__slist_make_link(&head,create_node(x));
}
// 注意,没有 push_back()
// 从头部取走元素(删除之)。修改 head。
void pop_front() {
list_node* node = (list_node*) head.next;
head.next = node->next;
destroy_node(node);
}
...
};

slist 的元素操作

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
38
39
#include <slist> 
#include <iostream>
#include <algorithm>
using namespace std;
int main()
{
int i;
slist<int> islist;
cout << "size=" << islist.size() << endl; // size=0
islist.push_front(9);
islist.push_front(1);
islist.push_front(2);
islist.push_front(3);
islist.push_front(4);
cout << "size=" << islist.size() << endl; // size=5
slist<int>::iterator ite =islist.begin();
slist<int>::iterator ite2=islist.end();
for(; ite != ite2; ++ite)
cout << *ite << ' '; // 4 3 2 1 9
cout << endl;
ite =find(islist.begin(), islist.end(), 1);
if (ite!=0)
islist.insert(ite, 99); //新元素会被安插在1的前面
cout << "size=" << islist.size() << endl; // size=6
cout << *ite << endl; // 1
ite =islist.begin();
ite2=islist.end();
for(; ite != ite2; ++ite)
cout << *ite << ' '; // 4 3 2 99 1 9
cout << endl;
ite = find(islist.begin(), islist.end(), 3);
if (ite!=0)
cout << *(islist.erase(ite)) << endl; // 2
ite =islist.begin();
ite2=islist.end();
for(; ite != ite2; ++ite)
cout << *ite << ' '; // 4 2 99 1 9
cout << endl;
}

练习程序中一再以循环巡访整个slist,并以迭代器是否等于slist.end() 做为循环结束条件,这其中有一些容易疏忽的地方。当我们呼叫end()企图做出一个指向尾端(下一位置)的迭代器,STL 源码是这么进行的:

iterator end() { return iterator(0); }

这会因为源码中如下的定义:

typedef __slist_iterator<T, T&, T*> iterator;

而形成这样的结果:

__slist_iterator<T, T&, T*>(0);//产生一个暂时对象,引发 ctor

从而因为源码中如下的定义:

__slist_iterator(list_node* x) : __slist_iterator_base(x) {}

而导致基础类别的建构:

__slist_iterator_base(0);

并因为源码中这样的定义:

1
2
3
4
5
6
struct __slist_iterator_base
{
__slist_node_base* node;//指向节点基本结构
__slist_iterator_base(__slist_node_base* x) :node(x) {}
...
};

而导致:

node(0);

总结

这一章节探索容器(序列式),可以发现一块新大陆,这里挑一点来说。

  • 在构建一个容器之前,需要先想好基础结构,是连续的空间呢,还是一个一个的节点,这导致了内存配置的不同。
  • 然后,需要继而构造迭代器,定义好走访的形式,是单向的还是双向的还是随机的,重载++、*、->等运算符。
  • 然后实作容器类:
    • 首先定义好一系列type供萃取使用;
    • 接着定义迭代器指针,以及迭代器相关的返回函数(如end());
    • 然后定义一系列构造函数和析构函数,又紧接着可以作出内存配置(都使用simple_alloc)、清空,元素建构、解构(construct和deconstruc全域函数)的操作;
    • 最后,作出容器动作函数,如插入、删除,以及一系列辅助操作。

关联式容器

image-20220708212926619

image-20220708213202176

概念

image-20220708213324148

image-20220708213425662

树的导览

这里只作简单介绍。

image-20220708213538944

二叉搜索树

首先需要是一棵二叉树:“任何节点最多只允许两个子节点。”

image-20220708213849297

image-20220708213902871

image-20220708213933983

image-20220708214021195

平衡二叉搜索树

image-20220708214102679

这种不平衡状态会导致搜索的对数时间变为常数时间。尽量保持平衡能节省搜索时间。

AVL树通过高度维护每个节点的平衡因子,以此来保持平衡。平衡破坏后具体的操作涉及RR、RL、LR、LL,即单旋转和双旋转,在数据结构已涉及,这里不详细展开了。

image-20220708214715999

image-20220708214804558

RB-tree(红黑树)

image-20220708214854587

image-20220708215232970

插入节点

image-20220708215346578

image-20220708215523697

状况1

image-20220708215740628

状况2

image-20220708215952231

第二次旋转是为了满足规则4,本身G(10)的左子树在不考虑ABC时是没有黑节点的,因此要再旋转一次,把8这个黑节点转上去。实际上,第一次旋转就是把内侧插入变成了外侧插入,所以内侧插入总能用一次旋转变成外侧插入。

状况3

image-20220708220601431

状况4

image-20220708220714966

因为前面考虑状况123时,X这个新节点都是带着子树考虑的(从真正的叶节点递归上来),那么此时就可以把P看成新的节点,子树看成AB,然后继续向上考虑,就回到状况123了。

由上而下的程序

image-20220708220955571

image-20220708221107493

P是红色,说明G是黑色。由于路径自上而下检查,因此S不会是红色,否则G这个节点就已经需要修改了。所以可以保证S是黑色,归类到状态1和2。

image-20220708221121614

节点设计

image-20220708221455607

image-20220708221727398

image-20220708221742592

迭代器

关于双层结构:以后作补充。

image-20220708222113319

image-20220708222145968

image-20220708222335201

image-20220708222354631

header节点在后面图5-17。

注:对header节点的补充

1
2
3
4
5
原文链接:https://blog.csdn.net/sinat_41619762/article/details/115124653

红黑树有一个特殊节点header。对于一棵空红黑树,header的left和right都指向自己,parent指向0。而红黑树不为空时,红黑树的根节点的parent指向header,header的parent指向根节点,header的left和right分别指向红黑树最左端和最右端的节点。于是可以方便地获得红黑树的根节点、最左节点和最右节点。
header节点是红色的,root节点是黑色的,这用来对它们做出一定的区别。
红黑树的begin()返回的迭代器指向的就是header的left节点,而end()返回的迭代器指向的是header自己。

如果是根节点,则根节点(node==root)的父节点是header(y),根据条件根节点右子节点是null,header的右子节点和父节点都是root,则执行一次while后:node会变为header,y会变为root。则此时不能直接把y返回,因为又回到root了,注意到此时node->right(header->right)==y,则可以用这个条件判断这个特殊状况,不加以判断的话得出的结果是有问题的。

decrement()也要结合header这一个节点理解。

image-20220708222409431

image-20220708222453859

image-20220708222512308

这里的状况不是插入节点的状况。

image-20220708222524909

红黑树数据结构

image-20220709202843598

image-20220709202912338

image-20220709202929089

image-20220709202951320

image-20220709203006027

image-20220709203022344

红黑树的构造与内存管理

image-20220709204110637

image-20220709204129927

image-20220709204145642

image-20220709204206488

红黑树元素操作

元素插入

image-20220709213914135

image-20220709213927566

image-20220709213956069

对于insert_unique(),注意**j–**是找按大小顺序找前一个节点,下面是一些辅助理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
原文链接:https://blog.csdn.net/jmh1996/article/details/103466017

while结束是,x为NULL也就是待插入点,而y为插入点的父节点。
接下来初始化一个迭代器j.
如果发现comp为true,这意味着x应该插入到y的左孩子,否则应该插入到右孩子。

如果comp为true,而且刚好j又是整个树的最小值,这说明当前要插入的值比整个树的最小值还要小。于是就直接调用__insert(x, y, v) 函数进行插入。整个辅助函数,我们后面深入。

如果comp为true,但是y不是指向最小值。那么就让 j 自减一下,此时 j 指向的元素,有关系:j.value<y.value。 而且一定会有v>=j.value。如果v<j.value,那么在执行while循环的时候就应该找到是 j(或j的左子树,同时y不可能在j的左子树) 而不是 y。

接下来:
比较j.value和 v 之间那个大。
如果条件为true,说明此时j.value<v ,OK,说明v是一个全新的元素。执行正常的插入辅助程序。
如果条件为false,意味着j.value>=v,又因为v>=j.value,联立这两个条件,必然有j.value==v,说明这个v就是一个重复的元素。

如何在数学上证明x=y ?只需分别证明:x>=y 且x<=y 就可以了

image-20220709215727673

image-20220709215759005

__insert操作中,对于x这个节点,经过while循环后一定是null。

平衡调整

树形和颜色的调整,与前面插入节点的四个状况相符,进行一次或两次单一的旋转,并且调整节点的颜色。左旋转和右旋转与AVL树的旋转是一样的道理,不过红黑树的节点有父节点,指针的操作有所不同。

这里首先判断父节点关于祖父节点的左右关系,从而正确获得伯父节点。可以得知新节点、父节点都是红的(因为这样才需要处理)。

  • 如果伯父节点为红,对应状况3和4,则根据前面“自上而下的程序”,改变相应节点颜色,x本身直接插入,而后检查x的祖父节点(也即while继续检查)。
  • 如果伯父节点为黑,则是状况1和2,也即新插入的节点位于内侧和外侧的问题。进行一次判断是否多做一次旋转即可。

image-20220709215947662

image-20220709220008411

image-20220709220109772

image-20220709220150211
示例

image-20220709220251639

image-20220709220318544

image-20220709220450193

image-20220709220520908

元素搜寻

image-20220709220420450

set(集合)

实作

集合的键值就是实值,实值就是键值。不允许两个元素有相同的键值。集合能自动排序,以红黑树作为底层机制。

image-20220710214344791

image-20220710214627910

image-20220710214643208

image-20220710214656094

image-20220710214716430

image-20220710214740470

image-20220710214754310

示例

image-20220710214813231

image-20220710214829559

map(映射)

实作

image-20220710220207679

image-20220710220222302

image-20220710220238807

image-20220710220255655

image-20220710220313214

image-20220710220334105

image-20220710220406454

image-20220710220423703

image-20220710220449046

示例

image-20220710220507625

image-20220710220526021

特别说明

image-20220710220540190

image-20220710220554639

image-20220710220609537

multiset

允许键值重复的set,底层使用红黑树的insert_equal()即可。

image-20220710222720765

multimap

允许键值重复的map,底层使用红黑树的insert_equal()即可。

image-20220710222118863

hashtable(哈希表)

概述

image-20220711144731720

image-20220711144908836

image-20220711145042016

image-20220711145057101

线性探测

平均插入成本太高了,平均情况下要线性寻访一半表格。

image-20220711145149421

image-20220711145224861

image-20220711145325523

二次探测

image-20220711145523339

image-20220711145609963

image-20220711145724005

image-20220711145848868

开链

image-20220711145946962

桶与节点

image-20220711150025546

image-20220711150109251

迭代器

image-20220711150634444

image-20220711150730477

数据结构

image-20220711152331040

image-20220711152349896

image-20220711152604784

image-20220711152617353

构造与内存管理

image-20220711161536527

image-20220711161554106

image-20220711161625439

image-20220711161640642

image-20220711161653339

image-20220711161705467

image-20220711161719098

image-20220711161730754

image-20220711161743803

image-20220711161755840

image-20220711161809369

image-20220711161821906

image-20220711161836283

示例

image-20220711161911045

image-20220711161928090

image-20220711161947105

image-20220711162006081

image-20220711162017737

image-20220711162032756

image-20220711162043490

image-20220711162054903

哈希函数

image-20220711162139133

image-20220711162151600

image-20220711162203492

image-20220711162215439

hash set

两种集合的主要区别是,搜寻元素按什么方式。(红黑树是以二叉搜索的方式,哈希表是以哈希映射,因此哈希集合的元素不会被自动排序),源码就不放出来了,了解即可。

image-20220713200753990

hash map

与hash set一样的情况。

image-20220713201037384

hash multiset & hash multimap

与hash set 一样的情况。

image-20220713201123939

image-20220713201227747

算法

概论

stl 算法总览

in-place操作,意思是所有的操作都是”就地“操作,不允许进行移动,或者称作原位操作,即不允许使用临时变量

image-20220713201515538

image-20220713201532118

image-20220713201601573 image-20220713201624630

image-20220713201644501

质变算法

image-20220713202024949

image-20220713202041673

非质变算法

image-20220713202302960

stl 算法一般形式

主要讲一些简单的规范

image-20220713202705509

image-20220713202723349

image-20220713202736186

image-20220713202747321

泛化过程

将算法独立于数据结构,即算法适用于不同类型的结构。

image-20220713210815481

从int型泛化到任意类型

image-20220713210914548

image-20220713211111525

image-20220713211303822

image-20220713211523358

从普通指针泛化到迭代器

image-20220713211651828

image-20220713211949549

image-20220713212053777

数值算法 <stl_numeric.h>

使用数值算法,要包含头文件<numeric>,实现在 <stl_numeric.h>

运用示例

image-20220713212515585

image-20220713212534684

accumulate

image-20220713212843679

image-20220713212857578

adjacent_difference

image-20220713213135915

image-20220713213159492

image-20220713213224780

inner_product

image-20220713213734522

image-20220713213752876

image-20220713213804662

partial_sum

image-20220713214139398

image-20220713214316624

image-20220713214328669

power

如果指数n是2的幂次方,则在第一个while就做完了,因为移位后低位一直是0,直到遇到唯一一个1变成最低位,期间x不断二次幂地执行op函数。记录算好的x,然后需要把n再右移一位(把前面说的那个‘1’扔掉,移除已经做完了的操作,即2的幂次方次操作)

此时如果n仍不为0,此时继续算:每次n右移一位代表x要再平方一次,如果右移的位是1,则result要加上;如果是0则忽略,继续向下迭代。

本质上直接用下面的while即可,但先用上面的while可以略去低位很多的连续0(如果有)。

image-20220713214514825

image-20220713214526809

itoa

image-20220713215739972

基本算法<stl_algobase.h>

运用示例

image-20220714095353452

image-20220714095451416

image-20220714095503217

image-20220714095524252

equal

image-20220714100118987

image-20220714100138700

fill & fill_n

image-20220714100404299

image-20220714100419586

iter_swap

image-20220714100707144

image-20220714100726524

lexicographical_compare

image-20220714101349204

image-20220714101406250

image-20220714101419397

image-20220714101433075

max

image-20220714101926677

min

image-20220714102002465

image-20220714102035256

mismatch

image-20220714102128380

image-20220714102148154

swap

image-20220714102312618

copy——极高的效率

assignment operator是赋值运算符

trivial意思是平凡的(原生的),也就是在类里面的构造函数(ctor)、复制构造函数(copy)、赋值函数(assignment)、析构函数(dtor)这四个函数满足至少一条:

  • 显式(explict)定义了这四种函数。
  • 类里有非静态非POD的数据成员。
  • 有基类。

则这些函数是non-trivial函数,否则就是trivial的。如果这个类都是trivial ctor/dtor/copy/assignment函数,我们对这个类进行构造、析构、拷贝和赋值时可以采用最有效率的方法,不调用无所事事正真的那些ctor/dtor等,而直接采用内存操作如malloc()、memcpy()等提高性能,这也是SGI STL内部干的事情。这里的copy,关键看类是否拥有trivial assignment

  • 如果迭代器指向的序列是字符串,那么直接使用字符串拷贝,效率会提高。
  • 如果迭代器本身是指针,那么它所指向的这个序列在内存上是连续存放的,所以可能可以使用memmove复制底层内存来加速。如果指针指向的类型拥有trivial operator=(例如int类型),那么直接使用memmove复制底层内存是一种更快的方法。
  • 如果迭代器本身就是迭代器,那么所指向的元素在内存上可能不是连续存放的,所以不能使用memmove。但是迭代器有分InputIterator和RandomAccessIterator,RandomAccessIterator可以加速复制过程。

image-20220714102441740

image-20220714103237927

image-20220714103636436

image-20220714103721723

image-20220714103734468

image-20220714104030718

image-20220714104101844

image-20220714104122221

image-20220714104232216

image-20220714104600986

1
2
3
4
5
泛化版本会借助一个__copy_dispatch结构体来应对不同的迭代器类型,从而调用不同版本的底层实现函数。__copy_dispatch结构体本质上还是一个模板结构体,通过传入的模板参数获取迭代器的类型,并调用相应版本的函数。此外,__copy_dispatch结构体还是一个函数对象,重载了operator()运算符,所以可以像调用函数一样使用__copy_dispatch结构体。

至于这里为啥要使用结构体来分发函数,而不是使用函数来分发函数,主要是因为结构体支持偏特化,而函数不支持偏特化,它只支持全特化。关于偏特化和全特化的区别,可以看这篇博客:https://harttle.land/2015/10/03/cpp-template.html。因为迭代器就算是指针,指针所指向的类型也是不确定的,而全特化不能拥有不确定的类型,它不仅需要确定迭代器类型是指针,而且还要确定指针所指向的类型,所以使用全特化不能够涵盖迭代器类型为指针的所有情况。而偏特化只是在模板的基础上进一步限定了模板参数的类型,但是它仍然可以拥有不确定的类型,所以只能用结构体偏特化。就像第一点中提到的对外接口,因为已经明确了参数必须是指向char或者wchar_t的指针,不存在不确定的类型,所以可以直接使用全特化版本。

原文链接:https://blog.csdn.net/Johnsonjjj/article/details/107743872

image-20220714104615587

注:这两本版本的__copy()根据最后一个参数iterator_tag区分。

image-20220714104637758

image-20220714104651650

image-20220714104706541

如果对象定义了赋值操作,即具备non-trivial assignment operator,则必须循环而使用对象的”=”赋值操作。

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
void* memmove(void* dst,const void* src,size_t count)
{
void* ret = dst;
//dst <= src表示,如果dst在src的前面,从前往后复制不会覆盖src中还没有复制的内容

if (dst <= src || (char*)dst >= ((char*)src + count))
{
//从前往后复制,则不会出现覆盖src中没有复制的内容
while(count--)
{
*(char*)dst = *(char*)src; //char类型指针,表示一个字节一个字节的复制
dst = (char*)dst + 1; //移动一个字节
src = (char*)src + 1;
}
}
else
{
//从后往前复制,则不会出现覆盖src中没有复制的内容
dst = (char*)dst + count - 1;//移动到末尾
src = (char*)src + count - 1;
while(count--)
{
*(char*)dst = *(char*)src;
dst = (char*)dst - 1; //移动一个字节
src = (char*)src - 1;
}
}
//返回dst的头指针,还方便左值操作。
//如:ptstr = memmove(ptstr,src,count); cout << memmove(ptstr,src,count);
return ret;
}

copy测试

image-20220714104728497

image-20220714104744428

image-20220714104759964

image-20220714104816163

image-20220714104833740

image-20220714104851661

image-20220714104910005

image-20220714104936585

copy_backward

image-20220714115555737

image-20220714115614257

image-20220714115632463

image-20220714115647479

image-20220714115701225

set相关算法

image-20220714202741237

image-20220714202816107

image-20220714202853930

image-20220714203000538

image-20220714203020249

并集 set_union

image-20220714203208465

image-20220714203324899

image-20220714203347384

image-20220714203402900

交集 set_intersection

image-20220714203637985

image-20220714203738532

image-20220714203753218

差集 set_difference

image-20220714203917427

image-20220714203932520

image-20220714203947129

对称差 set_symmetric_difference

image-20220714204235115

image-20220714204359475

image-20220714204413278

image-20220714204433754

heap算法

image-20220714204635123

其他算法

image-20220714204706662

单纯数据处理算法

image-20220714204733064

image-20220714204748664

image-20220714204808323

image-20220714204827113

image-20220714204841122

image-20220714204908691

image-20220714204920647

image-20220714204935019

image-20220714204949720

image-20220714205003607

adjacent_find

image-20220714210815066

count

image-20220714211135551

count_if

image-20220714211155105

image-20220714211209512

find

image-20220714211606347

find_if

image-20220714211646674

find_end

image-20220714211720897

image-20220714211742806

image-20220714211759600

image-20220714211814419

image-20220714211829901

find_first_of

image-20220714212919217

image-20220714212936044

for_each

image-20220714213308180

image-20220714213331032

generate

image-20220714220524077

generate_n

image-20220714220606074

includes(有序区间)

image-20220714220958628

image-20220714221456530

image-20220714221524967

image-20220714221720053

image-20220714221751558

max_element

image-20220714222730578

merge(有序区间)

image-20220714222811138

image-20220714222944765

image-20220714223003871

min_element

image-20220714223233423

partition(划分)

image-20220714223316705

image-20220714223338951

image-20220714223357058

remove

image-20220716105046338

image-20220716105123153

remove_copy

image-20220716105411061

remove_if

image-20220716105717210

image-20220716105731961

remove_copy_if

image-20220716105841970

replace

image-20220716110027436

replace_copy

image-20220716110043024

replace_if

image-20220716110055607

replace_copy_if

image-20220716110224638

reverse

image-20220716110326856

reverse_copy

image-20220716110626384

rotate

forward版本的,交换元素然后不断调整指针位置;

bidirectional版本的解法很妙。

random的就不关注了。

image-20220716110808262

image-20220716110829579

image-20220716110846069

image-20220716110913182

image-20220716110928287

image-20220716110942128

rotate_copy

image-20220716112051491

找的是连续的子序列

image-20220716112247627

image-20220716112302073

search_n

image-20220716112753477

image-20220716112833894

image-20220716112855924

image-20220716112934338

image-20220716112951504

swap_ranges

image-20220716114006360

transform

image-20220716114112746

image-20220716114125644

unique

image-20220716114413162

image-20220716114536405

unique_copy

image-20220716114624383

image-20220716114644130

复杂数据算法

示例

image-20220716115501986

image-20220716115524279

image-20220716115542322

image-20220716115557817

image-20220716115619070

lower_bound(有序区间)

image-20220716210016874

image-20220716210108304

image-20220716210129359

image-20220716210152811

image-20220716210216423

upper_bound(有序区间)

也即迭代器前面的元素≤value,注意lower_bound是**<**。一个是返回该元素第一次出现的位置,一个是返回最后一次出现的位置的下一个。如果没有value元素则返回的位置都相同。

image-20220716210639673

image-20220716210659587

image-20220716210722306

image-20220716210742882

binary_search(有序区间)

image-20220716211550228

image-20220716211606176

next_permutation

image-20220716212108470

image-20220716212318420

image-20220716212334221

image-20220716212602467

prev_permutation

image-20220716213159892

image-20220716213221596

random_shuffle

image-20220716213419143

image-20220716213507365

image-20220716213719328

partial_sort(_copy)

image-20220716213827980

image-20220716214008716

image-20220716214031121

image-20220716214048928

image-20220716214107696

sort

image-20220716215312982

image-20220716215341104

image-20220716215407570

insertion sort

image-20220716215433434

image-20220716215501735

image-20220716215517323

image-20220716215533099

quick sort

image-20220716220817021

image-20220716220832651

image-20220716220853155

image-20220716220943850

image-20220716221210031

这个函数没有(while中)对first和last作边界检查,而是以两个指针交错作为中止条件,节约了比较运算的开支。可以这么做的理由是因为,选择是首尾中间位置三个值的中间值作为pivot,因此一定会在超出此有效区域之前中止指针的移动(这其中while使用小于判断而不是小于等于判断起到了作用)。过程比较简单就不放出来了。

threshold

image-20220716221754812

image-20220716221834707

STL sort

image-20220716222842461

这里层数乘2是因为代码的while一层只递归一半,可见后面源码。

image-20220716222035643

image-20220716222108781

image-20220716222136506

image-20220716222205372

image-20220716222220997

image-20220716222236391

如何理解__final_insertion_sort函数呢?

  • 首先,unguarded版本的插入算法更快,因为不用边界检测。与常规版本相比,这个版本的插入算法直接跳入__unguarded_linear_insert函数(常规版本进入__linear_insert,先看是否比当前最小值小)。
  • 然后,注意到不用边界检测必然有一个前提条件,就是这个值不能比当前最小值小,即不能到最左边,所以必须保证全局最小值就在最左边的区间。
  • 最后,这个函数是如何保证最小值就在最前面的阈值大小的区间中呢?答案源于前面进行的__introsort_loop,该函数只有两种情况下可能返回:
    • 一是区域小于等于阈值16;二是超过递归深度阈值。我们现在只考虑最左边的子序列:
    • 先假设是由于第一种情况终止了这个函数,那么该子区域小于16。而根据快排原理,左边区间的所有数据一定比右边小,可以推断出最小值一定在该小于16的子区域内。
    • 假设函数是第二种情况下终止,那么对于最左边的区间,由于递归深度过深,因此该区间会调用堆排序,所以这段区间的最小值一定位于最左端(这段区间大小不一定,但数据一定都比右区间小)。再加上前面的结论:左边区间所有的数据一定比右边小,那么该区间内最左边的数据一定是整个序列的最小值。
    • 因此,不论是哪种情况,都可以保证起始的16个元素中一定有最小值。
  • 如若元素小于阈值,也即分支语句判断的那样,直接用常规的插入排序。

对sort的理解可以参考博客:https://feihu.me/blog/2014/sgi-std-sort/#为何__final_insertion_sort如此实现

equal_range(有序区间)

image-20220717101116777

image-20220717101150696

image-20220717101213233

image-20220717101236524

inplace_merge(有序区间)

常规的merge需要一个足够大的新区间,合并两个不一定连续的序列。

image-20220717102357897

注意这里两个序列是连接在一起的,缓存空间只需要能安置任何一个序列,就能简单由merge(backward)完成,因为这个merge过程使用缓存先存好了一个序列,往原来容器排序合并的过程中破坏了的元素不被需要(从缓存拿)。

image-20220717102412530

image-20220717102435562

image-20220717102451244

image-20220717102506599

image-20220717102519486

image-20220717102531964

针对case3:

  • 本质上是将两个序列先分成3个序列,其中中间的序列是从前面序列拿后部分元素,从后面序列拿前部分元素。然后再把中间序列分成两个序列,就有4个序列,对前两个、后两个做merge,就减少了长度;如果长度还是不够就继续减少,因为是递归调用。
  • 接着我们看看中间序列是怎么形成的:将长的序列分两半(对照下面例子就是len2),然后另一个序列用lower_bound,这是至关重要的一点。因为两个序列都是递增序列,则middle-cut2的元素<cut2指向的元素,这样序列一使用lower_bound得到的cut1指向的元素(包括到middle的元素)一定>middle-cut2的元素,因为cut1元素>=cut2元素。并且first-cut1之间的元素<cut2-last的元素(同样由lower_bound保证:cut1略过<cut2的元素,而到>=cut2元素时停下)
  • 紧接着用一个rotate旋转,将middle-cut2的元素与cut1-middle的元素互换位置,就能保证first-newmiddle之间的元素比newmiddle-last的元素都小。这样分别对两个区间递归merge(每个区间又包含两个小区间),就减少了长度,使得缓冲区能容纳。
  • 真相呼之欲出。实质上为了缩小序列,把两个序列分成了四个小序列,不妨设为l1、l2、l3、l4,l1、l2由前半序列分成,l3、l4由后半序列分成。为了分别做merge,需要保证左半边的序列已经比右半边序列元素小,但直接划分的序列不能保证这个情况,因此使用了旋转。因为l1<l2,l3<l4,而使用了lower_bound,能保证l2>l3,l4>l1。在旋转后得到l1、l3,l2、l4,就保证了l1、l3都小于l2、l4,再递归对两个序列merge即可。

image-20220717102544559

image-20220717102614400

image-20220717102633097

image-20220717102653956

image-20220717102707307

nth_element

这个不动点的保证是根据左边区间<nth<右边区间,nth即可得到与排序相同的值,不用管其他元素。

最后用插入排序是因为长度比较小,没必要再一点点分割了,直接排序完省事。

image-20220717110246499

image-20220717110302510

image-20220717110319387

image-20220717110339228

merge sort

image-20220717111608636

image-20220717111630333

仿函数(函数对象)

概观

理解:仿函数实际上是一个类或结构体,重载了**()**运算符使得其可以像函数一样调用。而且对比一般的函数指针又具有更多的功能,比如能使用模板、拥有继承等类之间的关系,利于资源管理(如果不是暂时对象可以保存数据)、使用成员变量免去一些全局变量的维护。

image-20220717212445635

image-20220717212620304

image-20220717212640627

image-20220717212701166

可配接(adaptable)的关键

image-20220717214155110

image-20220717214319801

unary_function

image-20220717214420971

binary_function

image-20220717214846421

算术运算类仿函数

image-20220717220011705

image-20220717220027680

image-20220717220356714

第一种实例化创建方式,在main()函数栈中,第二种方式作为cout对象的”<<”运算符函数的临时对象参数(在这个运算符函数的栈中),在cout完就结束了生命周期。如果类里定义了构造函数,则在括号里加上数值就可以使用构造函数,如plus<int>(123)(3,5),对应的不用临时对象的方式是plus<int> plusobj(123)(使用有参构造函数,否则使用无参构造函数或默认构造函数)。

image-20220717221533861

image-20220717221551371

关系运算类仿函数

image-20220717222058852

image-20220717222126027

image-20220717222140905

image-20220717222155928

逻辑运算类仿函数

image-20220717222242442

image-20220717222300935

证同、选择、投射

image-20220717222336065

image-20220717222352233

image-20220717222407811

配接器

image-20220718101755203

概观与分类

改变仿函数(functors)接口者,称为function adapter,改变容器(containers)接口者,称为container adapter,改变迭代器(iterators)接口者,称为iterator adapter。

应用于容器,container adapters

image-20220718102119349

应用于迭代器,iterator adapters

image-20220718102217547

image-20220718102233961

image-20220718102249018

image-20220718102307660

image-20220718102410136

应用于仿函数,functor adapters

image-20220718103049962

image-20220718103228312

image-20220718103322494

image-20220718103526544

image-20220718103813947

image-20220718103857300

image-20220718104016271

image-20220718104042713

image-20220718104100211

image-20220718104114616

container adapters

image-20220718105329217

iterator adapters

insert iterators

image-20220718145236565

image-20220718145300655

image-20220718145320497

image-20220718145342558

image-20220718145402441

reverse iterators

注意倒转后迭代器仍保持前闭后开!这使得逻辑位置改变(指向一个位置的正向、倒转迭代器取值不同,这是因为倒转迭代器先向前一步再取值),但只有这样才能保持正向迭代器的一切惯常行为。

image-20220718150323303

image-20220718150343569

image-20220718150434610

image-20220718150453407

image-20220718150510865

image-20220718150536096

image-20220718150552097

image-20220718150616231

image-20220718150632751

image-20220718150646413

stream iterators

image-20220718152645229

image-20220718152754751

image-20220718152810584

image-20220718152828514

image-20220718152845572

image-20220718155603786

image-20220718155625952

image-20220718155644000

image-20220718155707414

image-20220718155735301

用法

输入流简单用法
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
#include <iostream>
#include <iterator>
using namespace std;
int main() {
//用于接收输入流中的数据
double value1, value2;
cout << "请输入 2 个小数: ";
//一般用两个流迭代器来从流中读取全部的值:指向要读入的第一个值的开始迭代器,指向流的末尾的结束迭代器。在输入流的文件结束状态(End-Of-File,EOF)被识别时,就可以确定结束迭代器。

//创建表示结束的输入流迭代器
istream_iterator<double> eos;//使用默认构造函数
//创建一个可逐个读取输入流中数据的迭代器,同时这里会让用户输入数据
istream_iterator<double> iit(cin);
//判断输入流中是否有数据
if (iit != eos) {
//读取一个元素,并赋值给 value1
value1 = *iit;
}
//如果输入流中此时没有数据,则用户要输入一个;反之,如果流中有数据,iit 迭代器后移一位,做读取下一个元素做准备
iit++;
if (iit != eos) {
//读取第二个元素,赋值给 value2
value2 = *iit;
}
//输出读取到的 2 个元素
cout << "value1 = " << value1 << endl;
cout << "value2 = " << value2 << endl;
return 0;
}

程序执行结果为:

1
2
3
请输入 2 个小数: 1.2 2.3
value1 = 1.2
value2 = 2.3

注意,只有读取到 EOF 流结束符时,程序中的 iit 才会和 eos 相等。另外,Windows 平台上使用 Ctrl+Z 组合键输入 ^Z 表示 EOF 流结束符,此结束符需要单独输入,或者输入换行符之后再输入才有效。

输出流简单用法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
#include <iterator>
#include <string>
using namespace std;
int main() {
//创建一个输出流迭代器
ostream_iterator<string> out_it(cout);
//向 cout 输出流写入 string 字符串
*out_it = "http://c.biancheng.net/stl/";
cout << endl;

//创建一个输出流迭代器,设置分隔符 ,
ostream_iterator<int> out_it1(cout, ",");
//向 cout 输出流依次写入 1、2、3
*out_it1 = 1;
*out_it1 = 2;
*out_it1 = 3;
return 0;
}

程序输出结果为:

1
2
http://c.biancheng.net/stl/
1,2,3,

在实际场景中,输出流迭代器常和 copy() 函数连用,即作为该函数第 3 个参数。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
#include <iterator>
#include <vector>
#include <algorithm> // std::copy
using namespace std;
int main() {
//创建一个 vector 容器
vector<int> myvector;
//初始化 myvector 容器
for (int i = 1; i < 10; ++i) {
myvector.push_back(i);
}
//创建输出流迭代器
std::ostream_iterator<int> out_it(std::cout, ", ");
//将 myvector 容器中存储的元素写入到 cout 输出流中
std::copy(myvector.begin(), myvector.end(), out_it);
return 0;
}

程序执行结果为:

1
1, 2, 3, 4, 5, 6, 7, 8, 9,

function adapters

image-20220718204731386

image-20220718204821810

image-20220718205014836

image-20220718205041489

返回值逻辑否定 not1、not2

代码中常出现的pred一词,是predicate的缩写,意指这个函数会返回真假值(bool)的表达式。

image-20220718205558167

image-20220718205615187

参数绑定 bind1st、bind2nd

image-20220718210310958

image-20220718210324882

image-20220718210335848

函数合成 compose1、compose2

image-20220718211019187

image-20220718211033913

函数指针 ptr_fun

函数指针的前置知识可以参考:C/C++ 函数指针使用总结 - 白菜菜白 - 博客园 (cnblogs.com)

使用配接器可以使用模板。

image-20220718211227501

image-20220718212152451

image-20220718212208413

成员函数指针 mem_fun、mem_fun_ref

image-20220718212612550

image-20220718212738481

image-20220718212755625

image-20220718212812756

这其中S是函数返回值,T是函数所属的class类型。把这个类的对象传进来,获取对应的成员函数。

image-20220718213446601

image-20220718213505094

image-20220718213546265

image-20220718213559463

image-20220718213632081

后记

2022/7/18

将这本书笼统地过了一遍,大概花了将近一个月的时间。因为挺久没接触c++了,(课设写的都是纯c风格、之前平时用的是python),导致很多语法都忘记了。实际上是一个边学习边复习的过程,以至于依然有许许多多地细节等待我去发现,因此我会再花五六天的时间(甚至更多)重头开始再过一遍,这次更加注重细节(作深一番追究),以及整个宏观层次的思考和总结。不过,这些都交给明天的我吧~~

2022/7/28

第二遍大概是过完了,补充了一些要点,并且之前没看懂的部分这次也能看懂了,有的部分作了总结。中间也有休息几天,大概用了七八天吧,其中也在学计网。当然很多东西是不用只根据看就能明白的,所以接下来打算做一个小型的STL,这个项目在github上:Alinshans/MyTinySTL: Achieve a tiny STL in C++11 (github.com),已经近7kstar了,如果要进阶STL,可以一起来学习这个项目并且自己动手实践。

简介

总之就是非常痛苦 >_<

这篇博客就当个帖,想记录一下复习期末考的过程,作为情绪抒发和进度整理等等(甚至类似于日记)。

日程记录

Date 5/13

这学期专业课学到了很多东西,但是很多知识点都不太深入,对考试不是很有信心,而且因为疫情估计要线上考试,也是很不方便不舒服的吧。剩下的还有20来天就考试周了,所以要现在开始复习了。首先是要规划一下,按考试的顺序应该是:

  • 操作系统(D类)
  • 算法与复杂性
  • 计算机组成
  • 计算机科学中的数学基础
  • 计算机系统结构(A类)
  • 大学物理(A类)

很庆幸把编译原理退了,实在折腾不起。其中毛概貌似改成了线上大作业的形式所以这里没有放出来,不过老师说还要等通知(虽然毛概这些课只是考前一天狂背)。我的习惯的从后往前复习,这样对第一门考试印象会清晰些。

  • 大学物理这学期是在上量子力学,老实说没学明白,老师的课件和讲解也比较混乱,猜想是应该不会考那么难。但是现在什么信息都不知道,甚至也没有教材,所以打算先不复习这个(感觉这门课没什么作用就是了)。
  • 系统结构的话学得很烂,因为老师是选了几本书的内容来讲的,上课的时候没有结合书本来看,吸收得不是很到位。所以打算先再看看教材比较重点的章节,做做课后习题先。包括老师今天刚开放这学期的慕课,后面也可以看看慕课的视频讲得好不好(理论上慕课的成绩是课后题和模拟考试的成绩)。这门课的复习应该占的时间会比较多了。
  • 数学基础讲的太难了,不过好在作业有认真做(当然是谷歌solution,因为太难了,据说作业对比考试是sss难度)。具体复习的话打算看看课件(也是没用教材来学习,全英的pdf太折磨了),把一些公式再好好看看推导再记一下(但是推导也太难了,不知道会考成啥样),然后复习做过的题目。
  • 计组貌似期末是开卷(感觉开卷反而难一些),老师课讲得很好(太清晰明了了),知识点大都能理解,不过之前都没有复习导致都快忘了。所以一方面得再看看老师的ppt(老师上课很多手画的图没在ppt上,可能还得看视频了),一方面看看有没有题目做,感觉做起题目来还是很吃力。
  • 算法课也难啊,图算法和几何算法讲了好几周了,属实是太难了。还没想好要咋复习,估计就看看写过的作业吧。笔记这些也没有,感觉有点难复习了,也是只能再看看ppt和书了,找不到对应能巩固的习题来做。
  • 操作系统算是学得最明白的了,有一本中文教材太棒了。虽然课上用的是更新一版的英文版(中文版还没出),不过许多章节都能对应上。而且操作系统能出来考试的知识点比较明显吧,也做了相应的作业,感觉再看遍书,复习下作业题就好很多。当然还是希望能有题目做做巩固一下。

时间方面的具体安排就还没想好,只能说学得不是很好,还没有需要花多少时间复习的自知。好在还有时间,走一步看一步先。

Date 5/15

现在是周日晚上十一点多,回顾一下这个周末,摸得多学得少。物理作业屯着先不打算做(做了怕又忘记了),所以周末都用来复习了。不过实际上复习的时间也就六七个小时,效率还是太低了。

也算是有个好的开端,两天复习了系统结构的处理器的章节和浮点数的章节。处理器部分比较多,浮点数讲的比较少,但是复习做了做题也巩固了不少,算是把薄弱的补了一些了。

之后打算每天看看慕课的ppt什么的,然后把课后习题做了(两天一篇吧,章节还挺多。)ppt不知道是不是和课上一样的,还没瞅瞅。然后就边复习数学基础了,看看公式什么的。

\ 周末都是在CIEL的歌下度过的,好听嘞 /

img

Date 5/17

跟预想的有些偏差,不知道是记错了还是怎么样,原来考试时间要倒着看的,下面是刚查到的考试信息,有点阴间,能堆的都堆了,中间又间隔挺久的。最后一周的周末貌似就用来线上考毛概,得重新计划一下复习节奏了。

1
2
3
4
5
6
大学物理:6-6   15:40——17:40
系统结构:6-7 10:30——12:30
数学基础:6-10 10:30——12:30
计组 :6-10 15:40——17:40
算法 :6-16 15:40——17:40
操作系统:6-17 10:30——12:30

至于这两天在干啥嘞,本来打算复习数学基础的,结果来了许多作业,算是最后为数不多的作业了。除了上课就是把 os 作业写了,然后补了物理作业(还差两题没写出来,这量子力学一点例题都没有,纯摸黑  ̄へ ̄)。系统结构最后一次lab写了两个晚上了,现在还在等最后一题的程序出结果。

这两天在听re0的歌,下面两首强推(其他op和ed也都很好听)。

Date 5/19

昨天肝了体育作业(该死的慕课),然后晚上写了算法课的作业就草草结束了。

今天复习了数学基础,先速通一遍,现在看了大概一半了。前面部分学的还是比较烂,所以看ppt的时候一边回放了视频,第二次看视频就觉得清晰多了,不过太难的证明还是不打算看了。明天打算把后面的部分也过完。

然后这几天看完了命运石之门,好看的嘞,胸针真男人。

Date 5/26

几天没更新了-。-

最近几天在复习完数学基础之后复习了操作系统的前面部分,本来周一是打算复习完后面部分的,结果计划了回家,买了票等等,就没心思复习下去了。

返乡要准备好多东西,不过一点点准备好也就可以了,回去还要先隔离14days,考试也要在酒店考,后面两门应该就能在家里了。会不会发生很麻烦的事情倒也还不知道,感觉一个人也能复习得下去一些。

周五的高铁,明天早上。心里不知道什么感觉,有种释放了吧。困了3个多月,从早晨起来就是对着电脑一直到晚上,不怎么出宿舍,也很久没有好好运动了……

倒不会很惨,学校各种措施和保障都做得很好,只是还是会有些压抑吧,虽然学业上的压力冲淡了这些压抑。

现在是周四的12点多一些,前两天把系统结构的慕课的测验做的差不多了,今天做完它,然后好好收拾一下行李(以及心情)。

回去最想做的一件事就是悠哉地吃点好的喝点好的

希望一切顺利,一路顺风

/ 最近喜欢听的歌,卡罗尔与星期二里的 After the fire

Date 5/27

跑路成功

5:00——22:00

好累- -

Date 5/30

最后的教学周了,今天在复习物理,好折磨嗷

越发感觉对知识的掌握程度并不足以应付考试

这周刚布置了形策的大作业,还没想好啥时候写。周末还有毛概的考试,复习时间好像不是很够…

在酒店隔离,条件还是很不错的,并且一个人可以开始自律生活

现在每天十二点睡,中午再午睡一下,慢慢变得早起些

然后每天保持半个小时左右的运动量,出出汗,毕竟之前在学校宿舍很久很久没有丝毫运动了,作为康复训练吧

压力还是很大,几门课程的平时分根据作业的打分情况看感觉也没有到预期,甚至还不知道错哪里了,答案也不公布…(唉)

不打算摆烂,但是也不打算太肝了。就像是在痛苦与快感中获得相对平衡

又也许只是,在虚无的快乐中寻求一些充实

Date 6/1

儿童节快乐

这两天一天整理+复习了计组,一天看了系统结构的题目,明天接着再看一遍系统结构的题目,以及把形策大作业over

然后是周五看os,因为周五晚上要os小测了

周六看大物,周天做毛概,下周一就开始考试了

选课遇到了点问题,选人工智能和要补修(因为转专业)的问求(问题求解与实践)时间冲突了,只能退选人工智能这门课,否则影响保研。实际上也不差人工智能这一门课,但是这又影响到后面学期的选方向,没选这门课就不能选AI的那个方向了,需要另修完一个方向的学分,我换了个计算机图形学,到时的方向应该是虚拟现实技术之类的。不过还是会继续学AI,因此那个方向是用来修学分的,AI方向的课还是可以选(据说),应该会继续选来上,就是课会比较多了……

不管怎么说,后面走一步算一步吧,加油

Date 6/3

端午节快乐

隔离酒店的早餐加了个粽子,挺开心的

晚上考了个os的小测,平均线之上吧,都是多选题,很难选

其他时间摆了挺久,期末考越来越近了

哈哈感觉期末考有些打boss的意思了

在酒店待了一个星期了,吃得挺好睡得挺好,都挺好的

日子很平淡,老实说就算放假了也没想着要干嘛——呃想得有些远了

最近喜欢听的歌,希望未来有一天

能明白许许多多的,非常遥远的,没有名字的,安静的孤单的,星星的愿望

Date 6/5

现在是下午,刚做完毛概的考试大作业,题目挺水的,逻辑搞对在书上找找,以及网上搜搜就能答。不过感觉大家的答案可能都一样了,官话都那个味道,不知道怎么批改。前天晚上刚做了os的小测,30道选择题,有95%的多选题,英文概念题,做起来挺恶心的,是题库的题目,但是老师有些都没有讲。不过还是老老实实自己做了,在平均分之上,换算下来平均分的话是平时分扣5分,我是平时分扣3分,这个结果对一门课程来说挺不应该的,不知道老师会不会调分,就算不调分我也能接受吧。

然后这个是最新的考试时间,为了不受早上做核酸的影响,教务把考试都安排到下午和晚上了,日期倒是没有变化,起码早上可以不用那么早起吧。

1d2b82fbcba30c43fa8a29a03c36d1f

晚上接着复习物理,说实话已经看了几天物理了,感觉还不如前面花多点时间复习别的科目。但是明天就要考物理了也不能不继续复习,感觉自己的状态不是很好,复习起来也是有气无力的,没有前几个学期那么沉得住气,线上教学影响还是蛮大的。

这个学期考个正常分平均分就立大功了,不是很抱期望考高分,暑假再收拾收拾心情。

接下来就好好打boss了

Date 6/12

现在是周日,周五考完计组后周六好好放松了一天。

这几门科目的考试确实比较难,感觉发挥的也不是那么好吧,不过应该也不会那么差(希望吧)

后面还有两科今天要开始复习了,先花两天时间看操作系统,再花两天时间看算法,老实说算法还不知道会怎么考,后面有机会再了解了解

早上在阳台吹了很久的风,没有在思考,纯粹的放松。以后的职业选择估计也要变了,等考完试再理一理

最近看虫师被惊艳到的一首纯音乐,很惬意很飘渺

很理想

Date 6/18

今天是周六,出去和朋友玩了。昨天考完os累坏啦,周末好好放松一下,然后下周又要开始小学期了。

明天再规划一下暑假安排,等分数出来再给这个贴子来一个终结啦。

Date 6/26

今天成绩算是全部出来了,一般般比较正常。因为有很多同学缓考和重考,所以也不知道真实的排名,也就不说了。

整个过程算是很辛苦了,不过过去就好啦。

最近重新深入学c++,也感觉挺有意思的(虽然很难)。

这篇就到此为止啦,谢谢陪伴

(最后推一首歌

程序优化

对于原来的代码,尽管能够成功登录学校的网站,但依旧有很多地方可以优化。

  • 模型加载较慢,python加载本地训练好的模型还是太慢了,并且要打包成exe文件的话,torch这个库太大了,不可能再用自己的模型来完成项目。所以这里可以再用到之前的验证码识别库ddddocr来代替自己的识别模型。

  • 考虑到项目迁移的方便,每次都下载一个chromedriver.exe文件是比较麻烦的,禁用谷歌浏览器自动升级是个好的选择,但是有更好的替代方法。可以使用webdriver-manager这个库,它会自动下载对应的driver到缓存里。具体的使用方式是

    1
    2
    3
    4
    from webdriver_manager.chrome import ChromeDriverManager
    options = webdriver.ChromeOptions()
    options.add_experimental_option('excludeSwitches', ['enable-logging'])#忽略dirver的信息打印
    driver = webdriver.Chrome(ChromeDriverManager().install(),options=options)#可以在缓存里下载最新的driver
  • 上面两个解决方式分别能够优化程序的运行速度项目迁移性,但直接打包成.exe文件还是会产生其他的问题。

可执行文件优化

  • 程序正常运行的情况下会在结束时关闭浏览器,除非我们在程序末尾sleep一个很长的时间等待。然而我们希望程序进程在打开浏览器后就可以退出,而不必等待我们将浏览器关闭。可以使用options.add_experimental_option("detach", True),这个语句使程序结束后浏览器不会关闭,不需要程序一直等待。

  • 我们打包的文件会调用chromedriver.exe,尽管我们可以设置我们的可执行文件(.exe)运行时不出现cmd窗口,但chromedriver.exe依然会出现自己的窗口展示运行信息,而这会影响简结,我们希望这个窗口不出现。解决方法是修改selenium包中的service.py(selenium->webdriver->common->service.py)源码。如下图,注意数字必须相同。

    image-20220512161629179
  • 最后的问题是,我们的程序自动退出了,因为不将浏览器关掉,所以chromedriver.exe这个进程会一直留在内存里。如果我们多次打开我们的.exe文件,就会有很多个chromedriver.exe进程,尽管占用的空间很小,我们也希望能在程序结束后终止掉这个进程,这并不会导致我们的网页关闭。

    • 我们可以用"taskkill /im chromedriver.exe /F"这个命令来杀死这个进程,一种容易想到的方式是用os.system(command)来执行这条命令,但是这个方式会在执行时闪现出cmd执行窗口,不是我们希望的方式。

    • 更好的方式是使用python的另一个标准库subprocess的subprocess.call(command, creationflags=0x08000000)。因此就可以写成

      1
      subprocess.call("taskkill /im chromedriver.exe /F", creationflags=0x08000000)

以上就把代码优化完毕了,两个代码文件如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#captcha_fast.py
from PIL import Image
from selenium.webdriver.common.by import By
import time
def get_snap(driver): # 对目标网页进行截屏。这里截的是全屏
driver.save_screenshot('full_snap.png')
page_snap_obj=Image.open('full_snap.png')
return page_snap_obj

def get_image(driver): # 对验证码所在位置进行定位,然后截取验证码图片
scaling_ratio=1#系统显示的缩放比例
img = driver.find_element(By.XPATH, '//*[@id="captcha-img"]')
time.sleep(0.10)
location = img.location
size = img.size
left = location['x']*scaling_ratio
top = location['y']*scaling_ratio
right = left + size['width']*scaling_ratio
bottom = top + size['height']*scaling_ratio
page_snap_obj = get_snap(driver)
image_obj = page_snap_obj.crop((left, top, right, bottom))
image_obj.save('captcha.png')

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
38
39
40
41
#login_oc.py
from captcha_fast import get_image
from selenium import webdriver
import time
import os
import subprocess
from selenium.webdriver.common.by import By
import ddddocr

ocr = ddddocr.DdddOcr(use_gpu=True)
from webdriver_manager.chrome import ChromeDriverManager
options = webdriver.ChromeOptions()
options.add_experimental_option("detach", True)
options.add_experimental_option('excludeSwitches', ['enable-logging'])
driver = webdriver.Chrome(ChromeDriverManager().install(),options=options)
driver.maximize_window()#最大化

captcha_path='./captcha.png'
snap_path='./full_snap.png'
url='https://oc.sjtu.edu.cn/login/openid_connect'
# 构造请求头

driver.get(url) #打开网页

cur_title=driver.title
while(driver.title==cur_title):#失败则一直试
time.sleep(0.10) # 加载等待
get_image(driver)
#print("推理验证码......")
with open(captcha_path, 'rb') as f:
img_bytes = f.read()
captcha_res = ocr.classification(img_bytes)
driver.find_element(By.NAME, 'user').send_keys('username') # 填入用户名
driver.find_element(By.NAME, 'pass').send_keys('password') # 填入密码
driver.find_element(By.NAME,'captcha').send_keys(captcha_res) # 填入验证码
driver.find_element(By.ID,"submit-button").click()

os.remove(captcha_path)
os.remove(snap_path)
subprocess.call("taskkill /im chromedriver.exe /F", creationflags=0x08000000)#杀死进程,用os.system会有黑窗口闪现
print("ending......")

项目打包

最后进行项目的打包,我使用pyinstaller库来打包项目,用得比较习惯。首先在cmd窗口切换目录到python文件的目录,打包的命令为:

1
pyinstaller -w -i icon.ico login_oc.py
  • -w表明取消程序执行的命令行窗口
  • -i 表示添加打包文件的图标,后面的icon.ico就是我们使用的图标,要放在目录下,或者把路径写全。注意不能用其他后缀的图片,可以先去在线转换网站把图片转换成.ico格式,还需要注意的是,这个icon大小必须是16×16的。
  • 最后想说这里不使用 -F 这个参数打包的原因。
    • 我们希望程序响应快一些,-F会把所有的项目依赖的文件打包在一起,获得一个比较大的.exe文件,这会导致文件的加载速度变慢。而不用 -F 会导出一个文件夹,放置项目依赖的文件。
    • ddddocr这个库在打包的过程中,没有被打包进去。因此不使用 -F 恰好能让我们手动把这个库添加到工作目录下(所有的库和我们的.exe文件都在这个文件夹中,还包括很多其他的.dll文件等)。
    • 如果我们想做多个.exe文件登录不同的网站(我做了两个),程序使用的库基本都是一样的,仅仅是代码有一些修改,甚至只是url修改了以下。因此我们可以把多个.exe文件都放到一个工作目录下,共享这些项目依赖文件,这样就能很大地降低内存开销。

项目使用

在项目打包后,注意.exe文件不能移动到别的地方,要创建一个快捷方式,把这个快捷方式移动就可以在桌面或者其他地方使用了。

整个项目到此就结束了,有任何问题欢迎评论