C++ Primer(第4版)-第3部分:类和数据抽象

第12章 类

  1. 每个类可以没有成员,也可以定义多个成员,成员可以是数据、函数或类型别名。

  2. 成员函数必须在类内部声明,可以在类内部定义,也可以在类外部定义。如果在类内部定义,就默认是内联函数。
    内联函数有三种:
    (1)直接在类内部定义。
    (2)在类内部声明,加上inline关键字,在类外部定义。
    (3)在类内部声明,在类外部定义,同时加上inline关键字。注意:此种情况下,内联函数的定义通常应该放在类定义的同一头文件中,而不是在源文件中。这是为了保证内联函数的定义在调用该函数的每个源文件中是可见的。

  3. const 成员不能改变其所操作的对象的数据成员。const 成员函数的const关键字必须同时出现在声明和定义中,若只出现在其中一处,就会出现一个编译时错误。
    double avg_price() const;

  4. 为什么将类定义在头文件中?
    遇到右花括号,类的定义结束。一旦定义了类,就知道了类的成员,存储空间。在一个给定的源文件中,一个类只能被定义一次。如果在多个文件中定义一个类,那么每个文件中的定义必须是完全相同的。
    将类定义在头文件中,可以保证每个使用该类(要使用该类,必须#include该类的头文件)的文件中以同样的方式定义类。

  5. 避免同一个文件被包含多次,保证即使头文件在同一个文件中被包含多次,其定义只出现一次。
    (1) 使用头文件保护符 (#ifndef …… #endif)
    #ifndef SOMEFILE_H
    #define SOMEFILE_H
    … … // 声明、定义语句
    #endif

    优点:#ifndef的方式受C/C++语言标准支持。它不光可以保证同一个文件不会被包含多次,也能保证内容完全相同的两个文件(或者代码片段)不会被不小心同时包含。
    缺点:如果不同头文件中的宏名不小心“撞车”,可能就会导致你看到头文件明明存在,编译器却硬说找不到声明的状况。
    (2) 使用#pragma once
    #pragma once
    … … // 声明、定义语句

    pragma once一般由编译器提供保证:同一个文件不会被包含多次。注意这里所说的“同一个文件”是指物理上的一个文件,而不是指内容相同的两个文件。你无法对一个头文件中的一段代码作pragma once声明,而只能针对文件。
    优点:你不必再费劲想个宏名了,当然也就不会出现宏名碰撞引发的奇怪问题,大型项目的编译速度也因此提高了一些。
    缺点:是如果某个头文件有多份拷贝,本方法不能保证他们不被重复包含。当然,相比宏名碰撞引发的“找不到声明”的问题,这种重复包含很容易被发现并修正。

  6. 类声明(declare)
    class Screen;
    声明了一个类,但没有定义它。在声明之后,定义之前,只知道Screen是一个类名,但不知道包含哪些成员。只能以有限方式使用它,例如:是一个类名,但不知道包含哪些成员。只能以有限方式使用它,例如:定义指向该类型的指针或引用,声明(不是定义)使用该类型作为形参类型或返回类型的函数。例如:
    void Test1(Screen& a){};void Test1(Screen* a){};-

  7. 类定义(define)
    在创建类的对象之前,必须完整的定义该类,而不只是声明类。所以,类不能具有自身类型的数据成员,但可以包含指向本类的指针或引用。
    class LinkScreen
    {
    Screen window;
    LinkScreen* next;
    LinkScreen* prev;
    }; //注意,分号不能丢

  8. 类对象
    定义类时,不进行存储分配。
    定义类对象时,才分配存储空间。如: LinkScreen scr;

  9. 为什么类的定义以分号结束?
    因为在类定义之后可以接一个对象定义列表。定义必须以分号结束:
    class Sales_item { // };
    class Sales_item { // } accum, trans;

  10. mutable可变数据成员
    mutable可变数据成员永远都不能为const,所以,const成员函数可以改变mutable成员。

  11. 类作用域
    每个类都定义自己的作用域和唯一的类型。
    类的作用域包括类的内部(花括号之内), 定义在类外部的成员函数的参数表(小括号之内)和函数体(花括号之内)。
    注意:成员函数的返回类型不一定在类作用域中。可通过 类名::来判断是否是类的作用域,::之前不属于类的作用域,::之后属于类的作用域。例如
    char Screen::get(index r, index c) const
    {
    index row = r * width; // compute the row location
    return contents[row + c]; // offset by c to fetch specified character
    }
    Screen:: 之前的返回类型就不在类的作用域,Screen:: 之后的函数名开始到函数体都是类的作用域。

  12. 构造函数
    构造函数是特殊的成员函数,用来保证每个对象的数据成员具有合适的初始值。
    构造函数名字与类名相同,不能指定返回类型(也不能定义返回类型为void),可以有0-n个形参。
    在创建类的对象时,编译器就运行一个构造函数。

  13. 构造函数初始化式
    Sales_item::Sales_item(const string &book):isbn(book), units_sold(0), revenue(0.0) { }
    构造函数初始化列表以一个冒号开始,接着是一个以逗号分隔的数据成员列表,每个数据成员后面跟一个放在圆括号中的初始化式。构造函数初始化只在构造函数的定义中而不是声明中指定。
    可以认为构造函数分两个阶段执行:(1)初始化阶段;(2)普通的计算阶段。初始化列表属于初始化阶段(1),构造函数函数体中的所有语句属于计算阶段(2)。初始化列表比构造函数体先执行。不管成员是否在构造函数初始化列表中显式初始化,类类型的数据成员总是在初始化阶段初始化。

  14. 类对象的数据成员的初始化
    在类A的构造函数初始化列表中没有显式提及的每个成员,使用与初始化变量相同的规则来进行初始化。例如,类类型的数据成员,运行该类型的默认构造函数来初始化。内置或复合类型的成员的初始值依赖于该类对象的作用域:在局部作用域中不被初始化,在全局作用域中被初始化为0。假设有一个类A,
    class A
    {
    public:
    int ia;
    B b;
    };
    A类对象A a;不管a在局部作用域还是全局作用域,b使用B类的默认构造函数来初始化,ia的初始化取决于a的作用域,a在局部作用域,ia不被初始化,a在全局作用域,ia初始化0。
    可以初始化 const 对象或引用类型的对象,但不能对它们赋值。在开始执行构造函数的函数体之前,要完成初始化。初始化 const 或引用类型数据成员的唯一机会是构造函数初始化列表中,在构造函数函数体中对它们赋值不起作用。没有默认构造函数的类类型的成员,以及 const 或引用类型的成员,必须在初始化列表中完成初始化。

  15. 成员初始化的次序
    每个成员在构造函数初始化列表中只能指定一次。
    成员被初始化的次序就是定义成员的次序,跟初始化列表中的顺序无关。

  16. 初始化式可以是任意表达式
    Sales_item(const std::string &book, int cnt, double price): isbn(book), units_sold(cnt), revenue(cnt * price) { }

  17. 类类型的数据成员的初始化式
    初始化类类型的成员时,要指定实参并传递给成员类型的一个构造函数,可以使用该类型的任意构造函数。
    Sales_item(): isbn(10, ‘9’), units_sold(0), revenue(0.0) {}

  18. 默认构造函数
    定义一个对象时没有提供初始化式,就使用默认构造函数。为所有形参提供默认实参的构造函数也定义了默认构造函数。
    只有当一个类没有定义构造函数时,编译器才会自动生成一个默认构造函数。一个类哪怕只定义了一个构造函数,编译器也不会再生成默认构造函数。
    建议:如果定义了其他构造函数,也提供一个默认构造函数。
    如果类包含内置或复合类型(如 int& 或 string*)的成员,它应该定义自己的构造函数来初始化这些成员。每个构造函数应该为每个内置或复合类型的成员提供初始化式。

  19. 隐式类类型转换
    只含单个形参的构造函数能够实现从形参类型到该类类型的一个隐式转换。
    class Sales_item {
    public:
    Sales_item(const std::string &book = “”): isbn(book), units_sold(0), revenue(0.0) { }
    };
    string null_book = “9-999-99999-9”;
    item.same_isbn(null_book); 本行就实现了从null_book 到Sales_item 类型的隐式转换。

  20. 抑制由构造函数定义的隐式转换
    通过将构造函数声明为 explicit,来防止在需要隐式转换的上下文中使用构造函数:
    class Sales_item {
    public:
    // default argument for book is the empty string
    explicit Sales_item(const std::string &book = “”):
    isbn(book), units_sold(0), revenue(0.0) { }
    explicit Sales_item(std::istream &is);
    // as before
    };
    通常,除非有明显的理由想要定义隐式转换,否则,单形参构造函数应该为 explicit。将构造函数设置为 explicit 可以避免错误,并且当转换有用时,用户可以显式地构造对象。

  21. 友元
    友元机制允许一个类将对其非公有成员的访问权授予指定的函数或类。友元只能出现在类定义的内部的任何地方。友元不是授予友元关系的那个类的成员,所以它们不受声明出现部分的访问控制影响。建议将友元声明成组地放在类定义的开始或结尾

使其他类的成员函数成为友元
例如,在类A中要使类B中的成员函数C成为友元,必须先定义类B(在B中声明函数C),然后定义类A,在类A中声明B的成员函数C为友元,最后再定义C。

  1. static 成员变量
    static 数据成员是与类关联的对象,并不与该类的对象相关联。
    static 成员遵循正常的公有/私有访问规则。
  2. 使用 static 成员而不是全局对象有三个优点。
    (1) static 成员的名字是在类的作用域中,因此可以避免与其他类的成员或全局对象名字冲突。
    (2) 可以实施封装。static 成员可以是私有成员,而全局对象不可以。
    (3) 通过阅读程序容易看出 static 成员是与特定类关联的。这种可见性可清晰地显示程序员的意图。

  3. static 成员函数
    在类的内部声明函数时需要添加static关键字,但是在类外部定义函数时就不需要了。
    因为static 成员是类的组成部分但不是任何对象的组成部分,所以有以下几个特点:
    1) static 函数没有 this 指针
    2) static 成员函数不能被声明为 const
    3) static 成员函数也不能被声明为虚函数

  4. static 数据成员
    static 数据成员可以声明为任意类型,可以是常量、引用、数组、类类型,等等。
    static 数据成员必须在类定义体的外部定义(正好一次),并且应该在定义时进行初始化。建议定义在类的源文件中名,即与类的非内联函数的定义同一个文件中。注意,定义时也要带上类类型+”::”
    double Account::interestRate = 0.035;

  5. 特殊的整型 const static 成员
    整型 const static 数据成员可以直接在类的定义体中进行初始化,例如:
    static const int period = 30;

  6. static 数据成员的类型可以是该成员所属的类类型。非 static 成员只能是自身类对象的指针或引用:
    class Bar {
    public:
    // …
    private:
    static Bar mem1; // ok
    Bar *mem2; // ok
    Bar mem3; // error
    };

  7. 非 static 数据成员不能用作默认实参,static 数据成员可用作默认实参。
    class Screen {
    public:
    // bkground refers to the static member
    // declared later in the class definition
    Screen& clear(char = bkground);
    private:
    static const char bkground = ‘#’;
    };

第13章 复制控制

  1. 复制构造函数
    复制构造函数是一种特殊构造函数,只有1个形参,该形参(常用 const &修饰)是对该类类型的引用。当定义一个新对象并用一个同类型的对象对它进行初始化时,将显式使用复制构造函数。当将该类型的对象传递给函数或函数返回该类型的对象时,将隐式使用复制构造函数。

  2. 析构函数
    析构函数是构造函数的互补:当对象超出作用域或动态分配的对象被删除时,将自动应用析构函数。析构函数可用于释放构造对象时或在对象的生命期中所获取的资源。不管类是否定义了自己的析构函数,编译器都自动执行类中非 static 数据成员的析构函数。

  3. 复制构造函数、赋值操作符和析构函数总称为复制控制。编译器自动实现这些操作,但类也可以定义自己的版本。

  4. C++ 支持两种初始化形式:直接初始化和复制初始化。直接初始化将初始化式放在圆括号中,复制初始化使用 = 符号。
    对于内置类型,例如int, double等,直接初始化和复制初始化没有区别。
    对于类类型:直接初始化直接调用与实参匹配的构造函数;复制初始化先使用指定构造函数创建一个临时对象,然后用复制构造函数将那个临时对象复制到正在创建的对象。直接初始化比复制初始化更快。

  5. 形参和返回值
    当形参或返回值为类类型时,由复制构造函数进行复制。

  6. 初始化容器元素
    复制构造函数可用于初始化顺序容器中的元素。例如:
    vector svec(5);
    编译器首先使用 string 默认构造函数创建一个临时值,然后使用复制构造函数将临时值复制到 svec 的每个元素。

  7. 构造函数与数组元素
    如果没有为类类型数组提供元素初始化式,则将用默认构造函数初始化每个元素。
    如果使用常规的花括号括住的数组初始化列表来提供显式元素初始化式,则使用复制初始化来初始化每个元素。根据指定值创建适当类型的元素,然后用复制构造函数将该值复制到相应元素:
    Sales_item primer_eds[] = { string(“0-201-16487-6”),
    string(“0-201-54848-8”),
    string(“0-201-82470-1”),
    Sales_item()
    };

  8. 合成的复制构造函数
    如果我们没有定义复制构造函数,编译器就会为我们合成一个。
    合成复制构造函数的行为是,执行逐个成员初始化,将新对象初始化为原对象的副本。
    逐个成员初始化:合成复制构造函数直接复制内置类型成员的值,类类型成员使用该类的复制构造函数进行复制。
    例外:如果一个类具有数组成员,则合成复制构造函数将复制数组。复制数组时合成复制构造函数将复制数组的每一个元素。

  9. 定义自己的复制构造函数
    (1) 只包含类类型成员或内置类型(但不是指针类型)成员的类,无须显式地定义复制构造函数,也可以复制。
    (2) 有些类必须对复制对象时发生的事情加以控制。例如,类有一个数据成员是指针,或者有成员表示在构造函数中分配的其他资源。而另一些类在创建新对象时必须做一些特定工作。这两种情况下,都必须定义自己的复制构造函数。

  10. 禁止复制
    有些类需要完全禁止复制。例如,iostream 类就不允许复制。
    为了防止复制,类必须显式声明其复制构造函数为 private。

  11. 大多数类应定义复制构造函数和默认构造函数
    一般来说,最好显式或隐式定义默认构造函数和复制构造函数。只有不存在其他构造函数时才合成默认构造函数。
    如果定义了复制构造函数,也必须定义默认构造函数。

  12. 赋值操作符
    与复制构造函数一样,如果类没有定义自己的赋值操作符,则编译器会合成一个。
    Sales_item& operator=(const Sales_item &);

  13. 合成赋值操作符
    合成赋值操作符会逐个成员赋值:右操作数对象的每个成员赋值给左操作数对象的对应成员。除数组之外,每个成员用所属类型的常规方式进行赋值。对于数组,给每个数组元素赋值。

  14. 复制和赋值常一起使用
    一般而言,如果类需要复制构造函数,它也会需要赋值操作符。

  15. 析构函数
    构造函数的用途之一是自动获取资源;与之相对的是,析构函数的用途之一是回收资源。除此之外,析构函数可以执行任意操作,该操作是类设计者希望在该类对象的使用完毕之后执行的。

  16. 何时调用析构函数?
    撤销(销毁)类对象时会自动调用析构函数。
    变量(类对象)在超出作用域时应该自动撤销(销毁)。
    动态分配的对象(new A)只有在指向该对象的指针被删除时才撤销(销毁)。
    撤销(销毁)一个容器(不管是标准库容器还是内置数组)时,也会运行容器中的类类型元素的析构函数(容器中的元素总是从后往前撤销)。

  17. 何时编写显式析构函数?
    如果类需要定义析构函数,则它也需要定义赋值操作符和复制构造函数,这个规则常称为三法则:如果类需要析构函数,则需要所有这三个复制控制成员。

  18. 合成析构函数
    合成析构函数按对象创建时的逆序撤销每个非 static 成员,因此,它按成员在类中声明次序的逆序撤销成员。对于每个类类型的成员,合成析构函数调用该成员的析构函数来撤销对象。合成析构函数并不删除指针成员所指向的对象。 所以,如果有指针成员,一定要定义自己的析构函数来删除指针。
    析构函数与复制构造函数或赋值操作符之间的一个重要区别:即使我们编写了自己的析构函数,合成析构函数仍然运行。

  19. 大多数 C++ 类采用以下三种方法之一管理指针成员:
    1)指针成员采取常规指针型行为。这样的类具有指针的所有缺陷但无需特殊的复制控制。
    2)类可以实现所谓的“智能指针”行为。指针所指向的对象是共享的,但类能够防止悬垂指针。
    3)类采取值型行为。指针所指向的对象是唯一的,由每个类对象独立管理。

  20. 具有指针成员的对象一般需要定义复制控制成员。
    为了管理具有指针成员的类,必须定义三个复制控制成员:复制构造函数、赋值操作符和析构函数。这些成员可以定义指针成员的指针型行为或值型行为。

  21. 智能指针
    智能指针类与实现普通指针行为的类的区别在于:智能指针通常接受指向动态分配对象的指针并负责删除该对象,用户分配对象,但智能指针类删除它,因此智能指针类需要实现复制控制成员来管理指向其共享对象的指针,只有在销毁了指向共享对象的最后一个智能指针后,才能删除该共享对象,使用计数是实现智能指针类的最常用的方式。

  22. 值型类
    所谓值型类,实质具有值语义的类,其特征为:对该类对象进行复制时,会得到一个不同的新副本,对新副本所做的改变不会影响原有对象。

第14章 重载操作符与转换

  1. 重载操作符与内置操作符有什么异同?
    相同:操作符的优先级,结合性或操作数数目均相同
    不同:重载操作符必须具有至少一个类类型或枚举类型的操作数;重载操作符并不保证操作数的求值顺序,尤其是,不会保证内置逻辑 AND、逻辑 OR和逗号操作符的操作数求值。

  2. 类成员与非成员重载操作符
    重载一元操作符如果作为成员函数就没有(显式)形参,如果作为非成员函数就有一个形参。类似地,重载二元操作符定义为成员时有一个形参,定义为非成员函数时有两个形参。

  3. 将操作符设置为类成员还是普通非成员函数的原则:
    • 赋值(=)、下标([])、调用(())和成员访问箭头(->)等操作符必须定义为成员,将这些操作符定义为非成员函数将在编译时标记为错误。
    • 像赋值一样,复合赋值操作符通常应定义为类的成员,与赋值不同的是,不一定非得这样做,如果定义非成员复合赋值操作符,不会出现编译错误。
    • 改变对象状态或与给定类型紧密联系的其他一些操作符,如自增、自减和解引用,通常就定义为类成员。
    • 对称的操作符,如算术操作符、相等操作符、关系操作符和位操作符,最好定义为普通非成员函数。