《C++ PrimerPlus》 第十二章~第十三章

第一章 ~ 第八章

第九章 ~ 第十一章

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

动态内存和类

复习示例和静态成员

  • 所有的对象都共用一个静态成员副本。 不能在类声明中初始化静态成员变量 ,这是因为声明描述了如何分配内存,但并不分配内存。p428
  • 对于静态类成员,可以在类声明之外使用单独的语句来进行初始化,这是因为静态类成员是单独存储的,而不是对象的组成部分。注意下面的初始化语句,指出了类型,并使用了作用域运算符,但没有使用关键字static。p428

    1
    int StringBad::num_strings = 0;
  • 在构造函数中使用new来分配内存时,必须在相应的析构函数中使用delete来释放内存。如果使用new[]来分配内存,则应使用delete[]来释放内存。p429

特殊成员函数

  • C++提供了一些特殊的成员函数,它们会在一定条件下自动创建。p432
    • 默认构造函数,如果没有定义构造函数;
    • 默认析构函数,如果没有定义;
    • 复制构造函数,如果没有定义;
    • 赋值运算符,如果没有定义;
    • 地址运算符,如果没有定义。
    • C++11提供了另外两种特殊成员函数:移动构造函数和移动赋值运算符。这将在第十八章讨论。
  • 其中,隐式地址运算符返回调用对象的地址(即this指针的值),这与我们的初衷一致。主要引起问题的是复制构造函数和赋值运算符。p432
    • 复制构造函数 用于将一个对象复制到 新创键 的对象中。也就是说,它用于初始化过程中,而不是常规的复制过程中。类的复制构造函数原型通常如下:Class_name(const Class_name&); p433
      • 何时调用复制构造函数:每当程序产生了对象副本时,都将使用复制构造函数,如用赋值语句初始化,函数按值传递等。具体地说,当函数按值传递对象或者函数按值返回对象时,都将使用复制构造函数。因此,进行传递时,多用引用,可以减少调用复制构造函数的时间和存储新对象的空间。
      • 默认复制构造函数的功能:默认的复制构造函数逐个复制非静态成员,复制的是成员的值(按值复制,浅复制),如果成员本身就是类对象,则将使用这个类的复制构造函数来复制成员对象。静态函数不受影响,因为它们属于整个类。

复制构造函数容易引起的问题

  • 如果常规构造函数中设置了一个静态变量用于计录创建对象的个数,那么就应在复制构造函数中显示写出该逻辑,否则会导致计数结果不准确。p434
  • 由于隐式复制构造函数是按值进行复制的。在这成员变量中含有指针时是十分危险的,因为这样依赖两个对象中的成员指针就会指向同一块内存,如果其中一个对象被释放后,其指针指向的内存块可能会导致不确定的错误。另外,程序有可能会因为两次释放同一块内存而导致程序终止。具体的错误取决于系统和相关实现。p435
  • 深度复制。深度复制可解决上述的问题。复制时应当复制指针指向内容的副本,并将副本的地址赋给新的对象,这样一来,两个对象就是完全独立的。必须自定义复制构造函数的原因就在于,一些类成员是使用new初始化的、指向数据的指针,而不是数据本身。p435

警告: 如果类中包含了使用 new 初始化的指针成员, 应当自定义一个复制构造函数, 以复制指向的数据, 而不是指针, 这被称为 深度复制. 复制的另一种形式(成员复制或浅复制)只是复制指针的值. 因此, 浅复制仅仅复制指针本身的值, 而不会深入”挖掘”以复制指针指向的结构.

赋值运算符容易引起的问题

  • 赋值运算符的完整函数原型如下:Class_name & Class_name::operator = (const Class_name&);。它接受并返回一个指向类对象的引用。
  • 赋值运算符的功能以及何时使用:将已有的对象赋给另一个对象时,将使用重载的复制运算符。(注意,初始化赋值时,不会调用赋值运算符重载,而是调用复制构造函数)。p436
  • 赋值的问题:主要是由于浅复制造成的数据问题,由于赋值时是按值赋值的,导致指针变量会指向相同的地址。解决的方法是提供赋值运算符(进行深度复制)的定义,其实现与复制构造函数相似,但也有一些差别。p436
    • 由于目标对象是已经存在的对象,所以它可能引用了以前分配的数据,因此函数应使用delete[]来释放这些数据。
    • 函数应当避免将对象赋给自身,否则,给对象重新赋值前,释放内存操作就已经删除了对象内容。这一点可以通过程序逻辑实现:if(this == &s) return *this;
    • 函数应返回一个指向调用对象的引用。通过返回一个对象,函数可以想常规赋值操作那样,连续进行赋值。

改进后的新String类

  • 下面两种方式分配的内存量相同,区别在于前者与类析构函数兼容,而后者不兼容。p438

    1
    2
    str = new char[1];  //与析构函数中的delete []str; 兼容
    str = new char;
  • C++11空指针: 在C++98中,字面值0有两个含义:可以表示数字值零,也可以表示空指针,这使得阅读程序的人和编译器难以区分。有些程序员使用(void *) 0来标识空指针(空指针本身的内部表示可能不是零), 还有些程序员使用NULL, 这是一个表示空指针的 C 语言宏定义. C++11提供了更好的解决方案,引入新关键字nullptr,用于表示空指针。原来的表示依然合法,但建议使用nullptr。 p438

  • 静态成员函数: 不能通过对象调用静态成员函数,也不能使用this指针。如果静态成员函数是在公有部分声明,则可以使用类名和作用域解析符来调用它。 其次,由于静态成员函数不与特定的对象相关联,因此只能使用静态数据成员,不能访问其他成员数据。p441
  • 较早的get(char *, int)版本在读取空行后,字符串中第一个字符将是一个空字符。较新的C++标准则会返回false。p446

在构造函数中使用new时应注意的事项

  • 使用new初始化对象的指针成员时,必须注意下面几项:p446
    • 如果在构造函数中使用new来初始化指针成员,则应在析构函数中使用delete。
    • new和delete必须相互兼容。new对应于delete,new[]对应于delete[]。
    • 如果有多个构造函数,则必须以相同的方式使用new,要么都带中括号,要么都不带。因为只有一个析构函数,所有的构造函数都必须与它兼容。然而,可以在一个构造函数中使用new初始化指针,而在另一个构造函数中将指针初始化为空(0或C++11中的nullptr),这是因为delete(无论是否带[])可以用于空指针。
    • 应定义一个复制构造函数,通过深度复制将一个对象初始化为另一个对象。它应分配足够的空间来存储复制的数据,并复制数据,而不仅仅是数据的地址。另外,还应该更新所有受影响的静态类成员。
    • 应定义一个赋值运算符,通过深度复制将一个对象赋值给另一个对象。它应该检查自我赋值的情况,释放成员指针以前指向的内存,复制数据而不仅仅是数据的地址,并返回一个指向调用对象的引用。

有关返回对象的说明

返回指向const对象的引用

  • 返回的对象应该是函数参数传递进来的对象,并且是const的,所以需要返回const引用。p449

返回指向非const对象的引用

  • 返回的对象应该是函数参数传递进来的对象,但是由于参数不是const的,所以返回非const(当然也可以返回const)。常见的两种情况是重载赋值运算符以及重载与count一起使用的<<运算符。p449

返回对象

  • 如果返回的对象是被调用函数中的局部变量,则不应该按引用方式返回它,因为在调用函数执行完毕时,局部对象将调用其析构函数。p450

返回const对象

  • 返回const对象,该对象将不能作为右值,此时可以避免一些不必要的错误。p450

使用指向对象的指针

  • 利用new创建一个对象并令指针指向它,该对象会被分配到堆内存中,直到使用delete为止,该对象一直存在。p453

    1
    String * favorite = new String;
  • 使用对象指针时,需要注意几点:p454

    • 使用常规表示法来声明指向对象的指针:String * glamour;
    • 可以将指针初始化为指向已有的对象:String * second = &string_first;
    • 想要将创建一个新的对象,可以使用new来初始化指针:String * glamour = new String
    • 可以通过间接访问运算符->来调用类的方法。
    • 可以通过解除引用运算符( * )来活得对象。
  • 定位new运算符: 定位new运算符可以在分配内存时指定内存的位置:Sting * p1 = new (buffer) String; 。但是在使用时要注意以下几点:p457
    • 确保定义不同的对象时,二者使用的内存地址是不同的,且内存单元之间没有重叠
    • delete可以与常规new运算符配合使用,但不能与定位new运算符配合使用。有时需要通过显示调用析构函数来释放对象的内存。
    • 对于使用定位new运算符创建的对象,应以与创建顺序相反的顺序进行删除。原因在于,晚创建的对象可能依赖于早创建的对象。另外,仅当所有对象(定位new创建的)都被销毁后,才能释放用于存储这些对象的缓冲区。

复习各种技术

  • p459

初始化列表

  • 如果Classy是一个类,而mem1、mem2和mem3都是这个类的数据成员,则类构造函数可以使用如下的语法来初始化数据成员。该语法需要注意以下几点:p464
    • 这种格式只能由于构造函数;
    • 必须用这种格式来初始化非静态const数据成员(C++11之前);
    • 必须用这种格式来初始化引用数据成员。
      1
      2
      3
      Classy:Classy(int n, int m) :mem1(n), mem2(0), mem3(n*m+2){
      ...
      }

第十三章 类继承

13.1 一个简单的基类

  • 从一个类派生出另一个类时,原始类称为基类,继承类称为派生类。p481

13.1.1 派生一个类

  • 使用公有派生,基类的公有成员将成为派生类的公有成员。基类的私有部分只能通过基类的公有和 保护 方法访问。 class A : public B //A继承自B,且是公有继承 p483

13.1.2 构造函数:访问权限的考虑

创建派生类对象时,程序首先会创建基类对象,这意味着 基类对象应该在程序进入派生类构造函数之前被创建 。C++
使用成员初始化列表语法来完成这种工作。

派生类构造函数必须调用基类的构造函数,利用成员初始化列表语法来显式调用基类构造函数,如果没有显式调用,那么就会调用默认的基类构造函数。p484

1
2
3
4
5
6
7
8
9
10
derived::derived(type1 x, type2 y) : base(x,y){  //显式调用基类B的构造函数
...
}

derived::derived(type1 x, type2 y){ //该代码与下面的等效
...
}
derived::derived(type1 x, type2 y) : base(){
...
}

  • 有关派生类构造函数的要点如下:
    • 首先创建基类对象(在进入派生类构造函数之前就被创建)
    • 派生类构造函数应通过 成员初始化列表 将基类信息传递给基类构造函数
    • 派生类构造函数应初始化派生类新增的数据成员
  • 释放对象的顺序与创建对象的顺序相反,即首先执行派生类的析构函数,然后自动调用基类的析构函数。p485
  • 如果没有在成员初始化列表中提供基类构造函数,程序将使用默认的基类构造函数,成员初始化列表只能用于构造函数。p486

派生类与基类之间的特殊关系

  • 当基类的方法不是私有的(可以是公有或保护),派生类可以使用基类的方法。p488
  • 基类指针/引用可以在不进行显式类型转换的情况下指向/引用派生类对象(反之不行)。但是只能调用基类方法。p488

继承:is-a关系

  • 公有继承是最常用的方式(另外还有私有和保护继承),它建立一种is-a-kind-of(是一种)的关系,术语简称is-a。is-a关系的派生类对象也是一种基类对象,凡是可以对基类执行的操作,都可以对派生类执行。p489

多态公有继承

  • 实现多态公有继承的方式有以下两种:p490
    • 在派生类中重新定义基类的方法
    • 使用虚方法(关键字virtual只用与类声明的方法原型中,不用于方法定义)
  • 如果要在派生类中重新定义基类的方法,通常应将基类方法声明为虚的。这样,程序将根据对象类型而不是引用或指针的类型来选择方法版本。为基类声明一个虚析构函数也是一种惯例。p493
  • 在派生类方法中,标准技术是使用作用域解析符来调用基类方法。p496

静态联编和动态联编

  • 将源代码中的函数调用解释为执行特定的函数代码块被称为函数名联编(binding)。在编译过程中进行联编被称为静态联编(static binding),又称为早期联编(early binding)。但是有时候,无法在编译时确定使用哪个函数块(如虚函数),所以,编译器必须生成能够在程序运行时选择正确的虚方法的代码,这被成为动态联编(dynamic binding),又称为晚期联编(late binding)。p502

指针和引用类型的兼容性

  • 将派生类引用或指针转换为基类引用或指针被称为向上强制转换(upcasting)。如果不使用显式类型转换,则向下强制转换是不允许的。p502
  • 隐式向上强制转换使基类指针或引用可以指向基类对象或派生类对象,因此需要动态联编。C++使用虚成员函数来满足这种需求。p503

虚成员函数和动态联编

  • 编译器对非虚方法使用静态联编,对虚方法使用动态联编。因为虚方法是根据对象类型来选择的,而对象类型只有在运行时才能确定。非虚方法则是根据引用或指针的类型来选择方法,它们可以在编译时确定。p503
  • 为什么有两种联编类型以及为什么默认为静态联编: 动态联编的好处是可以重新定义类方法,但是在运行阶段跟踪对象类型会产生一定的开销,这使得动态联编没有静态联编效率高,这也是选择静态联编为默认方式的原因。p503
  • 虚函数的工作原理: C++规定了虚函数的行为,但将实现方法留给了编译器作者。通常,编译器处理虚函数的方法是,给每个对象添加一个隐藏成员。隐藏成员中保存了一个指向函数地址数组的指针。这种数组成为虚函数表(virtual function table,vtbl)。虚函数表中存储了为类对象进行声明的虚函数的地址。 派生类对象将包含一个指向独立地址表的指针。如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地址;如果没有重新定义虚函数,则保存函数原始版本的地址。调用虚函数时,程序将查看存储在对象中的vtbl地址,然后转向相应的函数地址表。p504
  • 根据工作原理可以得出,使用虚函数时,在内存和执行速度方面有一定的成本(虽然非虚函数效率高,但不具备动态联编功能),包括:p505
    • 每个对象都将增大,增大量为存储地址的空间;
    • 对于每个类,编译器都将创建一个虚函数地址表(数组);
    • 对于每个函数调用,都需要执行一项额外的操作,即到表中查找地址。

有关虚函数注意事项

  • 虚函数的一些要点:p505
    • 在基类方法的声明中使用关键字virtual可使该方法在基类以及所有的派生类(包括儿子的儿子)中是虚的。
    • 如果使用指向对象的引用或指针来调用虚方法,程序将使用为对象类型定义的方法,而不使用为引用或指针类型定义的方法。这称为动态联编或晚期联编。这种行为非常重要,因为这样基类指针或引用可以指向派生类对象。
  • 构造函数不能是虚函数。创建派生类对象时,将调用派生类的构造函数,而不是基类的构造函数,然后,派生类的构造函数会使用基类的构造函数,这种顺序不同于继承机制。因此,派生类不急成基类的构造函数。p505
  • 除非类不是基类,否则应该将析构函数声明为虚函数。p505
  • 友元不能是虚函数,因为友元不是类成员,而只有成员才能是虚函数。p505
  • 如果派生类没有重新定义函数,将使用该函数的基类版本。如果派生类位于派生链中,则将使用最新的虚函数版本。p506
  • 重新定义继承的方法并不是重载,而是将基类的方法隐藏,也可以看作是重写。由此,得出两条经验规则:第一,如果重新定义继承的方法,应确保与原来的原型完全相同,但如果返回类型是基类引用或指针,则可以修改为指向派生类的引用或指针。第二,如果基类声明被重载了,则应在派生类中重新定义所在的基类版本。如果只定义类部分版本,则其他版本将被隐藏。另外,如果不需要修改,则新定义直接调用基类版本即可,如void derived::show() { const(base::show()); }

访问控制:protected

  • 对于外部世界来说,保护成员的行为与私有成员相似,但对于派生类来说,保护成员的行为与公有成员相似。p507
  • 对于数据成员最好采用私有访问控制,同时通过基类方法使派生类能够访问基类数据。对于成员函数来说,保护访问控制很有用,它让派生类能够访问公众不能使用的内部函数。

抽象基类

  • 从多个类中抽象出它们的共性,将这些共性放在一个抽象基类(abstract base class,ABC)中,然后再从该ABC派生出这些类。ABC中有些方法不能直接实现,C++通过纯虚函数(pure virtual function)提供未实现的函数。纯虚函数声明的结尾处为=0,如下所示:

    1
    virtual double Area() const = 0; // a pure virtual function
  • 当类声明中包含纯虚函数时,则不能创建该类的对象,而只能作为基类使用。要成为真正的ABC,必须至少包含一个纯虚函数,原型声明中的=0是虚函数成为纯虚函数,一般纯虚函数没有定义,但C++允许纯虚函数有定义,即可以把所有派生类的某个共同操作作为纯虚函数的定义,然后在派生类重写该纯虚函数时调用。p509

13.7 继承和动态内存分配

如果基类使用动态内存分配,并重新定义赋值和复制构造函数,那么将怎么影响派生类的实现呢?有以下几种情况:

13.7.1 派生类不使用new

  • 如果基类使用了动态内存分配,而派生类未使用,那么就不需要为派生类定义显式析构函数、赋值和复制构造函数。 p516

13.7.2 派生类使用new

  • 如果派生类使用了new,就必须为派生类定义显示析构函数、赋值和复制构造函数。p517

13.8 类设计回顾

13.8.1 编译器生成的成员函数

  1. 默认构造函数

  如果没有定义任何构造函数,编译器将定义默认构造函数。

  1. 复制构造函数
      如果程序没有使用(显式或隐式)复制构造函数,编译器将提供原型,但不提供函数定义。

  2. 复制运算符
      默认的赋值运算符用于处理同类对象之间的赋值。不要将赋值与初始化混淆了。如果语句创建新的对象,则是用初始化。如果语句修改已有对象的值,则是赋值。

13.8.2 其他的类方法

  1. 构造函数

构造函数不同于其他类方法,因为它创建新的对象,而其他类方法只是被现有的对象调用。这是构造函数不被继承的原因之一,继承意味着派生类对象可以使用基类的方法,然而,构造函数在完成其工作之前,对象并不存在。

  1. 析构函数

一定要定义显式析构函数来世放类构造函数使用new分配的所有内存,并完成类对象所需的任何特殊的清理工作。对于基类,即使它不需要析构函数,也应提供一个虚析构函数。

  1. 转换

使用一个参数就可以调用的构造函数定义了从参数类型到类类型的转换。

  1. 按值传递对象引用传递

  2. 返回对象和返回引用

有些类方法返回对象,有些返回引用,返回对象涉及生成返回对象的临时副本。优先返回引用,但函数不能返回在函数中创建的临时对象的引用。

  1. 使用const
    可以是用const来确保方法不修改参数。

注意,如果函数将参数声明为指向const的引用或指针,则不能将该参数传递给另一个函数,除非后者也确保了参数不会被修改。

13.8.3 公有继承的考虑因素

  1. is-a关系

要遵循is-a关系。如果派生类不是一个特殊的基类,则不用使用公有派生。

  1. 什么不能被继承

构造函数是不能继承的,也就是说,创建派生类对象时,必须调用派生类的构造函数。 C++11新增了一种能够继承构造函数的机制,但默认是不能继承构造函数。

析构函数也是不能继承的。

赋值运算符是不能继承的。

  1. 赋值运算符

  2. 私有成员与保护成员

  3. 虚方法

  1. 析构函数
  1. 友元函数