第12章 类和动态内存分配

<c++ primer plus>第六版

12 类和动态内存分配

12.1 动态内存和类

12.1.1 示例和静态类成员

//以下两行代码等价
//  都是使用一个对象来初始化新对象,
//  调用的构造函数为: StringBad(const StringBad &);
StringBad sailor = sports;
StringBad sailor = StringBad(sports);

12.1.2 特殊成员函数

有些成员函数是自动定义的, c++自动提供的成员函数有:

  1. 默认构造函数(如果没有定义).
  2. 默认析构函数(如果没有定义).
  3. 复制构造函数(如果没有定义).
  4. 赋值运算符(如果没有定义).
  5. 地址运算符(如果没有定义).

如果类的构造函数中用到静态成员或使用动态内存分配, 则隐式的复制构造函数 和 隐式的赋值运算符 会引起一系列问题.

  1. 默认构造函数:

1.1 定义一个类Klunk, 且没提供任何构造函数, 则编译器将提供如下默认构造函数

Klunk::Klunk() {} //默认构造函数, 不接收任何参数, 也不执行任何操作.
Klunk lunk; //该语句会调用默认构造函数.
```cpp

1.2 如果定义了构造函数, 则编译器将不会定义默认构造函数, 如果需要不带参数的构造函数, 需要自己定义.
```cpp
Klunk::Klunk() //定义不带参数的构造函数
{
    klunk_ct = 0;
}

1.3 带参数的构造函数也可以是默认构造函数, 只要所有参数都有默认值.

    Klunk (int n=0)
    {
        klunk_ct = n;
    }

但是只能有一个默认构造函数, 如下两个默认构造函数有二义性, 当用户使用Klunk bus语句时, 将匹配两个构造函数, 会报错.

Klunk () { klunk_ct = 0; }
Klunk (int n=0) { klunk_ct = n; }
  1. 复制构造函数: 将一个对象复制到新创建的对象中. 它用于初始化过程中, 而不是常规赋值过程中.

2.1 复制构造函数原型:

ClassName(const ClassName &) //接受一个指向对象的常量引用作为参数.

2.2 何时调用复制构造函数

新建一个对象, 并将其初始化为同类现有对象时, 将调用复制构造函数.
假设motto是StringBad的对象, 则下面4语句要调用复制构造函数
    StringBad ditto(motto);
    StringBad metoo = motto;
    StringBad also  = StringBad(motto);
    StringBad * pStringBad = new StringBad(motto);
每当程序生成了对象副本时, 编译器都将使用复制构造函数: 函数按值传递对象, 函数返回对象.

2.4 默认复制构造函数的功能:
默认的复制构造函数逐个复制非静态成员(成员复制也称为浅复制), 复制的成员的值. 静态函数不受影响, 因为它们不属于各个对象.
比如:
cpp StringBad sailor = sports,
等价于:
cpp StringBad sailor; sailor.str = sports.str; sailor.len = sports.len;

12.1.4. 赋值运算符

c++允许类对象赋值, 这是通过自动为类重载赋值运算符实现的.
ClassName & ClassName::operator=(const ClassName &);
它接受一个指向类对象的引用, 并返回一个指向类对象的引用.

  1. 赋值运算符的功能 以及 何时使用它

将已有的对象赋值给另一个对象时, 将使用重载的赋值运算符.

StringBad headline1("Celery Stalks at Midnight");
StringBad knot;
knot = headline1; //将调用赋值运算符

注意: 初始化对象时, 并不一定会使用赋值运算符:
StringBad metoo = knot; //将调用复制构造函数(实现时可能分两步: 1. 使用复制构造函数创建一个临时对象, 然后调用赋值运算符将临时对象复制到新对象).

所以: 初始化总是会调用复制构造函数, 而使用=运算符时也可能调用赋值运算符.

与复制构造函数相似, 赋值运算符的隐式实现也对成员进行逐个复制. 如果成员本身就是类对象, 则程序将使用为这个类定义的赋值运算符来复制该成员. 静态数据不受影响.

12.2 改进后的新String类

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

使用new初始化对象的指针时要特别小心:

  1. 如果在构造函数中使用new来初始化指针成员, 则应在析构函数中使用delete;
  2. new和delete必须相互兼容: new对应delete, new[]对应delete[];
  3. 如果有多个构造函数, 则必须以相同的方式使用new(要么都带中括号, 要么都不带). 因为只有一个析构函数.
  4. 应该定义一个复制构造函数, 通过深度复制将一个对象初始化为另一个对象.
    复制构造函数应该分配足够的空间来存储复制的数据, 并复制数据, 而不仅仅是数据的地址.
  5. 应该定义一个赋值运算符, 通过深度复制将一个对象复制给另一个对象.

12.4 有关返回对象的说明

当成员函数或独立函数返回对象时, 有几种返回方式:

  1. 返回指向对象的引用;
  2. 返回指向对象的const引用;
  3. 返回对象;
  4. 返回const对象;

12.4.1 返回指向const对象的引用

返回const引用的主要目的是提高效率.

Vector force1(50, 60);
Vector force2(10, 70);
Vector max;
max = Max(force1, force2);

其中Max函数的以下两种实现方法都可行:

//version 1, 返回对象
Vector Max(const Vector &v1, const Vector &v2)
{
    if (v1.magval()>v2.magval())
        return v1;
    else
        return v2;
}

//version 2, 返回引用
const Vector & Max(const Vector &v1, const Vector &v2) //第一个const表示返回值是const
{
    if (v1.magval()>v2.magval())
        return v1;
    else
        return v2;
}

注意:

  1. 返回对象将调用复制构造函数, 而返回引用则不会. 所以version 2所做的工作更少, 效率更高.
  2. 引用指向的对象应该在调用函数执行时存在.
  3. 函数参数v1和v2都被声名为const引用, 而函数返回v1或v2, 所以返回类型也必须为const, 这样才匹配.

12.4.2 返回指向非const对象的引用

有两种常见的情形要返回非const对象(前者旨在提高效率, 后者必须这样做):

  1. 重载赋值运算符;
  2. 重载与cout一起使用的<<运算符;

operator=()的返回值用于连续赋值:

String s1("Good Stuff");
String s2, s3;
s3 = s2 = s1;

这里s2.operator=()的返回值被赋值给s3, 返回对象或返回引用都可行, 但返回引用可避免调用String的复制构造函数来创建一个新的String对象.

operator<<()的返回值用于串接输出:

String s1("Good Stuff");
cout << s1 << " is coming!";

这里operator<<(cout, s1)的返回值成为一个用于显示字符串" is coming!"的对象.
返回类型必须是ostream &, 而不能是ostream. 如果返回ostream, 将会调用ostream类的复制构造函数, 但ostream没有公有的复制构造函数.

12.4.3 返回对象

注意: 如果被返回的对象是被调用函数中的局部变量, 则不能按引用的方式返回它. 因为函数执行完后局部变量将调用其析构函数, 引用指向的对象将不再存在.
即: 返回局部变量时, 应该返回对象, 而不是返回引用.

通常, 被重载的算术运算符属于这一类.

例如:

Vector force1(50, 60);
Vector force2(10, 70);
Vector net;
net = force1 + force2;

返回的不是force1也不是force2, 因此返回值不能是调用函数时已经存在的对象的引用, 而是新的临时对象.

Vector Vector::operator+(const Vector &b) const //最后一个const表示是const函数, 不能修改类成员
{
    return Vector(x+b.x, y+b.y);
}

这时, 存在调用复制构造函数(用来创建被返回的对象)的开销, 然而这是无法避免的.

12.4.4 返回const对象

以如下3个语句为例:

net = force1 + force2; //1, 将两个对象相加, 赋值给第三个对象.
force1 + force2 = net; //2, 将第三个对象赋值给两个对象相加.
cout << (force1 + force2 = net).magval() << endl; //3, 在2的基础上再调用对象方法.

其中第2/3条语句比较奇怪, 提三个问题:

  1. 为何编写这样的语句?
    没有要编写这种语句的理由, 但并非所有代码都是合理的.

  2. 这些语句为何可行?
    因为表达式force1+force2的结果为一个临时对象(复制构造函数将创建一个临时对象来表示返回值).
    在语句1中, 将该临时对象赋值给net.
    在语句2和3中, 将net赋值给该临时对象.

  3. 这些语句有何功能?
    使用完临时对象后, 将把它丢弃.
    比如语句2, 程序计算force1与force2之和, 将结果复制到临时变量中, 再后net的内容覆盖临时对象的内容, 然后将该临时对象丢弃, 原来的矢量全都保持不变.
    比如语句3, 程序显示临时对象的长度, 然后将其删除.

如果担心force1 + force2 = net这种语句可能引发的误用和滥用(比如在条件判断语句中将force1+force2==net误写为force1+force2=net),
有一种简单的解决方案: 将返回类型声明为const Vector, 则由于语句2和语句3都有对临时对象的赋值操作, 所以这两语句是非法的.

总结:

  1. 如果方法或函数要返回局部对象, 则应该返回对象, 而不是指向对象的引用(因为局部对象在函数结束后就不存在了).
  2. 返回对象时, 将使用复制构造函数来生成返回的对象.
  3. 如果要返回一个没有公有复制构造函数的函数的类(如ostream类)的对象, 它必须返回指向这种对象的引用.
  4. 有些方法或函数(如重载的赋值运算符), 既可以返回对象也可以返回指向对象的引用, 这时应首选引用, 因为其效率更高.

12.5 使用指向对象的指针

如果: ClassName是类, value的类型为TypeName, 则如下语句:

ClassName * pclass = new ClassName(value); //声明一个指向对象的指针, 将调用构造函数ClassName(TypeName);

如下初始化方式:

ClassName *ptr = new ClassName; //将调用默认构造函数

12.5.1 再谈new和delete

在构造函数中使用new为对象分配存储空间, 在析构函数中使用delete来释放这些内存.
String * favorite = new String(sayings[choice]);

注意: 这是为对象分配内存, 而不是为要存储的字符串分配内存. 也就是说分配的内存情况为:

  1. 保存字符串地址的str指针的内存,
  2. len成员的内存,
  3. 不给num_string成员分配内存, 因为它是静态成员, 它独立于对象被保存.

创建对象时将调用构造函数, 在构造函数中才会分配用于保存字符串的内存, 并将字符串的地址赋值给str.
当程序不再需要该对象时, 使用delete删除它.

程序删除对象时, 将只释放用于保存str指针和len成员的空间, 并不释放str指向的内存.
释放str指向的内存的任务由析构函数来完成.

在下述情况下, 将调用析构函数:

  1. 如果对象是动态变量, 当执行完定义该对象的程序块时, 将调用该对象的析构函数.
  2. 如果对象是静态变量(外部, 静态, 静态外部, 来自名称空间), 则在结束时, 将调用该对象的析构函数.
  3. 如果对象是用new创建的, 则仅当显式地使用delete删除对象时, 才会调用该对象的析构函数.

12.5.2 指针和对象小结

使用对象指针时, 要注意几点:

  1. 使用常规表示法来声明指向对象的指针 : String * glamour;
  2. 将指针初始化为指向已有的对象 : String * first = &sayings[0];
  3. 使用new来初始化指针(将创建一个新对象) : String * favor = new String(sayings[choice]);
  4. 对类使用new, 将调用类构造函数初始化新建对象: String * gleep = new String; //调用默认构造函数
    String * glop = new String("My my my"); //调用相应参数类型的构造函数
  5. 通过 指针-> 运算符来访问类方法 : if (sayings[i].length() < shorted->length())
  6. 对对象指针使用解除引用运算符(*)来获得对象 : if (sayings[i] < *first)

12.5.3 再谈定位new运算符

定位new运算符的作用: 在分配内存时能够指定内存位置.

#include <iostream>
#include <string>
#include <sstream>
#include <new>

using namespace std;

const int BUF = 512;

class JustTesting
{
private:
    string words;
    int number;
public:
    JustTesting(const string &s="Just Testing", int n=0)
    {
        words = s;
        number = n;
        cout << "construct : " << words << endl;
    }

    ~JustTesting()
    {
        cout << "destroy   : " << words << endl;
    }

    void Show() const
    {
        cout << words << ", " << number << endl;
    }
    
    string to_str()
    {
        string str;
        stringstream ss;
        ss << number;
        ss >> str;

        return words + ", " + str;
    }
};

int main()
{
    char * buffer = new char[BUF]; // get a block of memory

    JustTesting *pc1, *pc2;

    pc1 = new (buffer) JustTesting;     // place object in buffer, 创建一个512字节的内存缓冲区
    pc2 = new JustTesting("Heap2", 20); // place object on heap

    cout << endl;
    cout << "Memory block addresses:" << endl;
    cout << "    buffer: " << (void *)buffer << endl;
    cout << "    heap  : " << pc2 << endl;
    cout << endl;
    cout << "Memory contents:" << endl;
    cout << "    " << pc1 << ": " << pc1->to_str() << endl;
    cout << "    " << pc2 << ": " << pc2->to_str() << endl;
    cout << endl;

    JustTesting *pc3, *pc4;
    pc3 = new (buffer) JustTesting("Bad Idea", 6); //会覆盖pc1对应的内存单元
    pc4 = new JustTesting("Heap4", 10);

    cout << "Memory contents:" << endl;
    cout << "    " << pc3 << ": " << pc3->to_str() << endl;
    cout << "    " << pc4 << ": " << pc4->to_str() << endl;
    cout << endl;

    delete pc2;       //会调用析构函数
    delete pc4;       //会调用析构函数
    delete [] buffer; //不会调用析构函数
    cout << "Done" << endl;

    return 0;
}

教训1:
pc1和pc3对应的缓冲区内存单元相同, 会引发问题, 所以需要提供位于缓冲区的两个地址, 比如:
pc1 = new (buffer) JustTesting;
pc3 = new (buffer + sizeof(JustTesting)) JustTesting("Better Idea", 6);

教训2:
如果使用定位new运算符来为对象分配内存, 必须确保其析构函数被调用.

在堆中创建的对象可以使用delete pc2, 但缓冲区中的不能使用delete pc1.
因为delete可以与常规new运算符配合使用, 但不能与定位new运算符配合使用.

delete [] buffer释放了使用常规new运算符分配的整个内存块, 但它没有为定位new运算符在该内存块中创建的对象调用析构函数, 需要显式地调用:
pc3->~JustTesting();
pc1->~JustTesting();

12.7 队列模拟

队列是一种抽象的数据类型(Abstract Data Type, ADT), 可以存储有序的项目序列.
队列: 在队尾添加项目, 在队首删除项目(FIFO).
栈 : 在同一端进行添加和删除(LIFO).

本节定义一个Queue类(第16单将介绍标准模板库类queue).

12.7.1 队列类

  1. Queue类接口:
class Queue
{
    enum {Q_SIZE=10};
private:
    //to be developed later
public:
    Queue(int qs=Q_SIZE);   //构造函数, 指定队列长度, 创建一个空队列
    ~Queue();               //析构函数
    bool isempty() const;   //常函数, 队列是否为空
    bool isfull() const;    //常函数, 队列是否为满
    int queuecount() const; //?
    bool enqueue(const Item &item); //给队列添加项目, 可以使用typedef来定义Item(见第14章类模板)
    bool dequeue(Item &item);       //给队列删除项目
}
Queue line1;    //一个队列, 最多10个项目(默认值)
Queue line1(20);//一个队列, 最多20个项目
  1. Queue类的实现
  1. 如何表示队列数据:
    一种方法是使用new动态分配一个数组, 但数组不适合队列操作.
    一种方法是使用链表, 每个节点都包含两个信息: 项目信息和下一节点的指针.
struct Node
{
    Item item;          //存储在node中的数据
    struct Node * next; //指向下一个Node的指针
};
  1. 单向链表, 每个节点都只包含一个指向下一个节点的指针, 最后一个节点的指针设置为NULL/nullptr.
  2. 让Queue类的一个数据成员指向链表第一个元素, 用于跟踪链表.
  3. 让Queue类的一个数据成员指向链表最后一个元素, 方便将新项目添加到队尾.
  4. 让Queue类的数据成员来跟踪队列可存储的最大项目数以及的项目数.
class Queue
{
private:
    struct Node //在class中嵌套struct声明, 使Node的作用域为整个class, 不与其它class或全局声明冲突.
    {
        Item item;
        struct Node * next;
    };
    enum {Q_SIZE = 10};

    Node * front;    //指向队列Queue的头
    Node * rear ;    //指向队列Queue的尾
    int items;       //队列Queue中当前项目数
    const int qsize; //队列Queue的最大项目数
    ...
public:
    ...
};
  1. 类方法
Queue::Queue(int qs)     //构造函数, 队列开始是空的
{
    front = rear = NULL; //队首队尾设置为NULL
    items = 0;           //项目数为0
    qsize = qs;          //最大长度从函数参数qs获取(这行代码有问题, 见后描述).
}

上述代码有个问题, qsize是常量, 只能对它初始化(在执行函数体之前, 即创建对象时进行初始化), 不能给它赋值.
c++提供了特殊语法来应对const赋值的操作, 它叫做成员初始化列表(member initializer list).
示例修改如下:

Queue::Queue(int qs): qsize(qs) //构造函数, 带成员初始化列表
{
    front = rear = NULL; //队首队尾设置为NULL
    items = 0;           //项目数为0
}

通常:
1) 初始化对象: 可以是const成员, 也可以是非const成员,
2) 初始化值 : 可以是参数列表中的参数, 也可以是常量(NULL/0等)
3) 只有构造函数可以使用这种初始化列表语法.
4) 对于const成员, 必须使用这种初始化列表语法;
5) 对于被声明为引用的类成员, 也必须使用这种语法(因为引用也只能在创建时进行初始化);
6) 数据成员被初始化的顺序必须与它们出现在类声明的中的顺序相同, 与初始化器中的排列顺序无关.

//构造函数, 带成员初始化列表,
//初始化对象: 可以是const成员, 也可以是非const成员,
//初始化值  : 可以是参数列表中的参数, 也可以是常量(NULL/0等)
Queue::Queue(int qs): qsize(qs), front(NULL), rear(NULL), items(0)
{
}
//引用类型的成员必须在初始化列表中初始化
class Agency{...};
class Agent
{
private:
    Agency & belong; //一个引用, 必须在初始化列表中初始化.
};

Agent::Agent(Agency &a): belong(a){...} //在初始化列表中初始化.

成员初始化列表的语法:
Classy是一个类, mem1/mem2/mem3是这个类的成员,
Classy::Classy(int m, int n): mem1(m), mem2(0), mem3(m*n+2)
{
...
}

c++11可以在类内初始化, 但优先级比初始化列表低.
class Classy
{
int mem1 = 10;
const int mem2 = 20;
};

posted @ 2022-07-10 14:27  编程驴子  阅读(30)  评论(0编辑  收藏  举报