C++——类和动态内存分配

时间:2022-04-08 20:01:57

 

一、动态内存和类

  1、静态类成员

   (1)静态类成员的特点

     无论创建多少对象,程序都只创建一个静态类变量副本。也就是说,类的所有对象都共享同一个静态成员。

  (2)初始化静态成员变量

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

     2)静态类成员初始化是在方法文件中,而不是在类声明文件中进行的。这是因为类声明位于头文件中,程序可能将头文件包含在其他文件中。如果在头文件中进行初始化,将出现多个初始化语句副本,从而引发错误。

     3)对于不能在类声明中初始化静态成员的一种例外情况是,静态数据成员为const整数类型或枚举型。即,静态数据成员在类声明中声明,在包含类方法的文件中初始化。初始化使用作用域运算符来指出静态成员所属的类。但如果静态成员是const整数类型或枚举型,则可以在类声明中初始化。

Person.h

 1 #include <iostream>
 2 #include <string>
 3 class Person{
 4 private:
 5     static std::string race;//种族,静态类成员。除了const修饰的静态整数类型或枚举可以在类声明中进行初始化,其他的的静态成员的初始化都应该放在类方法文件中
 6     int age;
 7     std::string name;
 8 public:
 9     Person(std::string name_ = "无名氏", int age_ = 0);
10     ~Person();
11     friend std::ostream & operator<<(std::ostream & os,const Person & per);
12 };

 

Person.cpp

 1 #include "Person.h"
 2 std::string Person:: race = "黄种人";//初始化静态成员,需要有作用域运算符
 3 Person::Person(std::string name_, int age_){
 4     name = name_;
 5     age = age_;
 6 }
 7 std::ostream& operator<<(std::ostream &os,const Person & per){
 8     os << "姓名:" << per.name << ",年龄:" << per.age << ",种族:" << per.race;
 9     return os;
10 }

 

main.cpp

 1 #include <iostream>
 2 #include "Person.h"
 3 
 4 using namespace std;
 5 
 6 int main(int argc, const char * argv[]) {
 7     Person per1{"小强",30};
 8     Person per2{"小黄",24};
 9     cout << per1 << "\n" << per2 << endl;
10     per1.changeRace("白种人");
11     cout << per1 << "\n" << per2;
12     return 0;
13 }
14 
15 输出结果:
16 姓名:小强,年龄:30,种族:黄种人
17 姓名:小黄,年龄:24,种族:黄种人
18 姓名:小强,年龄:30,种族:白种人
19 姓名:小黄,年龄:24,种族:白种人

 

  2、特殊成员函数

  删除对象可以释放对象本身占用的内存,但是并不能释放属于对象成员的指针指向的内存。在构造函数中使用new来分配内存时,必须在相应的析构函数中使用delete来释放内存。如果使用new[](包括中括号)类分配内存,则应使用delete[](包括中括号)来释放内存。

  在设计类的时候,C++自动提供了下面的这些成员函数:

    *默认构造函数,如果没有定义构造函数;

    *默认析构函数,如果没有定义;

    *复制构造函数,如果没有定义;

    *赋值运算符,如果没有定义;

    *地址运算符,如果没有定义;

  隐式地址运算符返回调用对象的地址(即this指针的地址)。C++11提供了另外两个特殊成员函数:移动构造函数和移动赋值运算符。

  (1)默认构造函数

    如果没有提供任何构造函数,C++将创建默认构造函数,是一个不接受任何参数,也不执行任何参数的构造函数,创建出来的对象的值是不确定的;

    如果定义了构造函数,C++将不会定义默认构造函数,这个时候需要自己提供默认构造函数。如果希望在创建对象时不显式地对他进行初始化,则必须显式地定义默认构造函数。这种构造函数没有任何参数,但可以用它来设置特定的值。

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

    一个类只能有一个默认构造函数。

  (2)复制构造函数

    复制构造函数用于将对象复制到新创建的对象中。也就是说,它用于初始化过程中(包括按值传递参数),而不是常规的赋值过程。类的复制构造函数原型通常如下:

      Class_name(const Class_name &);

    它接受一个指向类对象的常量引用作为参数。对于复制构造函数,需要知道两点:何时调用和有何功能。

    1)何时调用复制构造函数

      *新建一个对象并将其初始化为同类现有对象时,复制构造函数将被调用。这在很多情况下都可能发生,最常见的情况是将新对象显式地初始化为现有的对象。例如,假设motto是一个Person对象,则下面的4种声明都将调用复制构造函数:

          Person metoo(motto);

          Person ditto = motto;

          Person also = Person(motto);

          Person *pPer = new Person(motto);

      *每当程序生成对象副本时,编译器都将使用复制构造函数。具体地说,当函数按值传递对象或函数返回对象,都将使用复制构造函数。记住,按值传递意味着创建原始变量的一个副本。编译器生成临时对象时,也将使用复制构造函数。

      由于按值传递对象将调用复制构造函数,因此应该按引用传递对象。这样可以节省调用构造函数的时间以及存储对象的空间。

    2)默认的复制构造函数的功能

      *默认的复制构造函数逐个复制非静态成员(成员复制也称为浅复制),复制的是成员的值。

      *如果类成员本身也是类对象,则将使用这个类的复制构造函数来复制成员对象。静态成员不受影响,因为他属于整个类,而不是各个对象。

     提示:如果类中包含静态数据成员,其值将在新对象被创建时发生变化,则应该提供一个显式复制构造函数来处理静态数据成员的值变化问题。

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

  (3)赋值运算符

    C++允许类对象赋值,这是通过自动为类重载赋值运算符实现的。这种运算符的原型如下:

      Class_name & Class_name:: operator=(const Class_name &);

    它接受并返回一个指向类对象的引用。

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

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

      *初始化对象时,并不一定会使用赋值运算符。用已有对象初始化新建对象时,总会调用复制构造函数,而使用=运算符时也允许调用赋值运算符。在使用=以已有对象初始化新建对象的时候,可能只使用复制构造函数来初始化新建对象;也有可能使用复制构造函数创建一个临时对象,然后通过赋值运算符将临时对象的值复制到新对象中。

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

    2)解决赋值的问题

      对于由于默认赋值运算符不合适而导致的问题(常见情况是类成员为使用new初始化的指针),解决办法是提供赋值运算符(进行深度复制)定义。其实现与复制构造函数相似,但也有区别:

      *由于目标对象可能引用了以前分配的数据,所以函数应使用delete来释放这些数据。

      *函数应当避免将对象赋给自身;否则,给对象重新赋值前,释放内存操作可能删除对象的内容。

      *函数返回一个指向调用对象的引用。

    通过返回一个对象,函数可以像常规赋值操作那样,连续进行赋值。

    请仔细阅读下面的例子:

String.h

 1 #include <iostream>
 2 class String{
 3 private:
 4     char *str;
 5     size_t len;
 6     static int num_strings;//静态成员不能在类声明中声明的时候进行初始化;但是包含const的整数类型或枚举静态成员可以在类声明中初始化
 7 public:
 8     String(const char *);
 9     String(const String &);//复制构造函数
10     String();//默认构造函数;如果没有定义构造函数,c++将提供默认构造函数;但是,如果定义了构造函数,就需要自己提供默认构造函数
11     ~String();
12     friend std::ostream& operator<<(std::ostream & , const String &);
13     String & operator=(const String &);//赋值运算符,需要返回调用对象本身的引用
14     
15 };

 

String.cpp

 1 #include "String.h"
 2 #include <ostream>
 3 int String::num_strings = 0;//静态成员在方法文件中进行初始化,初始化的时候不带关键字static,但是要使用作用域运算符
 4 String::String(const char * s){
 5     len = strlen(s);
 6     num_strings ++;
 7     str = new char[len + 1];
 8     strcpy(str, s);
 9     std::cout << num_strings << ":创建了\"" << str << "\"对象,现有"<< num_strings << "条\n";
10 }
11 String::String(const String & s){//定义复制构造函数
12     len = s.len;
13     str = new char[len + 1];
14     strcpy(str, s.str);
15     num_strings ++;
16     std::cout << num_strings << ": 创建了\""<<str << "\"对象,现有" << num_strings <<"条\n";
17 }
18 String::String(){
19     len = 3;
20     str = new char[4];
21     strcpy(str, "C++");
22     num_strings ++;
23     std::cout << num_strings << ": 创建了默认对象\"" << str << "\",现有" << num_strings <<"条\n";
24 }
25 String & String:: operator=(const String &s){
26     if (this == &s) {//先判断是否是将对象自身赋值给自己
27         return *this;
28     }
29     len = s.len;
30     delete [] str;//先释放对象指针成员之前的指向的内存,然后根据参数对象使用new重新分配内存
31     str = new char[len + 1];
32     strcpy(str, s.str);
33     return *this;//赋值运算符应该返回调用对象的引用,这样就可以实现连续运算
34 }
35 std::ostream & operator<<(std::ostream & os, const String & s){
36     os << s.str ;
37     return os;
38 }
39 String::~String(){
40     std::cout << "删除了\"" << str << "\"对象,还剩";
41     delete [] str;
42     --num_strings;
43     std::cout << num_strings << "条\n";
44 }

 

main.cpp

 1 #include <iostream>
 2 #include "String.h"
 3 
 4 using namespace std;
 5 
 6 void callme1(String &);
 7 void callme2(String);
 8 
 9 int main(int argc, const char * argv[]) {
10     String str1("小红");
11     String str2{"小哈"};
12     String str3{"牛逼哄哄"};
13     cout << "str1:" << str1 << "\n str2:" << str2 << "\n str3:" << str3 << endl;
14     //cout << "按引用传递str1:\n";
15     callme1(str1);
16     cout << "按值传递str2:\n";
17     callme2(str2);
18     cout << "赋值:\n";
19     String str4;
20     str4 = str2;
21     cout << "str4:" << str4 << endl;
22     return 0;
23 }
24 void callme1(String & str){
25     cout << "按引用传递:\n";
26     cout << "    \"" << str << "\"\n";
27 }
28 void callme2(String str){
29     //cout << "按值传递:\n";
30     cout << "    \"" << str  << "\"\n";
31 }
32 
33 
34 输出结果:
35 1:创建了"小红"对象,现有1条
36 2:创建了"小哈"对象,现有2条
37 3:创建了"牛逼哄哄"对象,现有3条
38 str1:小红
39  str2:小哈
40  str3:牛逼哄哄
41 按引用传递:
42     "小红"
43 按值传递str2:
44 4: 创建了"小哈"对象,现有4条
45     "小哈"
46 删除了"小哈"对象,还剩3条
47 赋值:
48 4: 创建了默认对象"C++",现有4条
49 str4:小哈
50 删除了"小哈"对象,还剩3条
51 删除了"牛逼哄哄"对象,还剩2条
52 删除了"小哈"对象,还剩1条
53 删除了"小红"对象,还剩0条

   1)、中括号运算符的重载说明:在C++中,两个中括号组成一个运算符——中括号运算符,可以使用operator[]()来重载该运算符。需要注意的是,像中括号这种形式的运算符,位于最前面的是第一个操作数,位于运算符中间的是第二个操作数。例如,在表达式city[0]中,city是第一个操作数,[]是运算符,0是第二个操作数。

  2)、可以将成员函数声明为静态的(函数声明必须包含关键字static,但如果函数定义是独立的,则其中不能包含关键字static),这样做有两个重要的后果:

      *首先,不能通过对象调用静态成员函数;实际上,静态成员函数甚至不能使用this指针。如果静态成员函数是在公有部分声明的,则可以使用类名和作用域运算符来调用它。

      *其次,由于静态成员函数不与特定的对象相关联,因此只能使用静态数据成员。

     同样,也可以使用静态成员函数设置类级标记,以控制某些类接口的行为。

 

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

  在使用new初始化对象的指针成员时必须特别小心。具体地说,应当这样做:

  *如果在构造函数中使用new来初始化指针成员,则应在析构函数中使用delete;

  *new和delete必须相互兼容。new对应delete,new[]对应于delete[];

  *如果有多个构造函数,则必须以相同的方式使用new,要么都带中括号,要么都不带。因为只有一个析构函数,所有的构造函数都必须与他兼容。然而,可以在一个构造函数中使用new初始化指针,而在另一个构造函数中将指针初始化为空(0或C++11中的nullptr),这是因为delete(无论是带中括号还是不带中括号)可以用于空指针。

  *应定义一个复制构造函数,通过深度复制将一个对象初始化为另一个对象。复制构造函数应分配足够的空间来存储复制的数据,并复制数据,而不仅仅是数据的地址。另外,还应该更新所有受到影响的静态类成员。

  *应定义一个赋值运算符,通过深度复制将一个对象复制给另一个对象。具体的说,赋值运算符应完成这些操作:检查自我赋值的情况,释放成员指针以前指向的内存,复制数据而不仅仅是数据的地址,并返回一个指向调用对象的引用。

  

三、有关返回对象的说明

  当成员函数或独立的函数返回对象时,有几种返回方式可供选择。可以返回指向对象的引用、指向对象的const引用或const对象。

  1、返回指向const对象的引用

  使用const引用的常见原因是旨在提高效率,但对于何时可以采用这种方式存在一些限制。如果函数返回(通过调用对象的方法或将对象作为参数)传递给他的对象,可以通过返回引用来提高效率。

  有三点需要说明:

  *首先,返回对象将调用复制构造函数,而返回引用不会;

  *其次,引用指向的对象应该在调用函数执行时存在;

  *第三,返回类型必须与函数类型相匹配。

  2、返回指向非const对象的引用

  两种常见的返回指向非const对象的引用的情形是,重载赋值运算符以及重载与cout一起使用的<<运算符。前者这样做是旨在提高效率,而后者是必须这样做。

  3、返回对象

  如果被返回的对象是被调用函数中的局部变量,则不应按引用返回它,因为在被调用函数执行完毕时,局部对象将调用析构函数。因此当控制权回到调用函数时,引用指向的对象将不再存在。在这种情况下,应返回对象而不是引用;同时,存在调用复制构造函数来创建被返回的对象的开销,然而这是无法避免的。通常,被重载的算术运算符属于这一类。

  4、返回const对象

  假设一个只有int类型的数据成员lenth的类Vector存在Vector::operator+()的定义如下:

    Vector Vector::operator+(const Vector &v){

      int len = lenth + v.lenth;

      Vector v2 = len;

      return v2;

    }

  它旨在能够以下面这样的方式使用它:

    net = force1 + force2;

  然而,这种定义也允许这样使用它:

    force1 + force2 = net;

    cout << (force1 + force2 = net).lenth() << endl;

  像下面的语句存在的错误在于:

  *首先,没有编写这种语句的合理理由;

  *其次,这种代码之所以可行,是因为复制构造函数创建了一个临时对象来表示返回值。因此,net被赋给了临时对象。

  *第三,使用完临时对象后,将把他丢弃。

  避免这种错误的一种简单的解决方案:将返回值类型声明为const。

 

  总之,如果方法或函数要返回局部对象,则应返回对象,而不是指向对象的引用。在这种情况下,将使用复制构造函数来生成返回对象。如果方法或函数要返回一个没有公有复制构造函数的类的对象,它必须返回一个指向这种对象的引用。最后,有些方法或函数(如重载的赋值运算符)可以返回对象,也可以返回指向对象的引用,在这种情况下,应首选引用,因为其效率更高。

 

四、使用指向对象的指针 

  

 1 #include <iostream>
 2 #include "String.h"
 3 #include <cstdlib>
 4 #include <ctime>
 5 
 6 const int ArSize = 10;
 7 const int MaxLen = 81;
 8 
 9 using namespace std;
10 
11 int main(int argc, const char * argv[]) {
12     String name;
13     cout << "Hi,what's your name?\n";
14     cin >> name;
15     
16     cout << name << ",please enter up to" << ArSize << " short saying <empty line to quit>:\n";
17     String saying[ArSize];
18     char temp[MaxLen];
19     int i;
20     for (i = 0; i < ArSize; i++){
21         cout << i + 1 << ":";
22         cin.get(temp,MaxLen);
23         while (cin && cin.get() != '\n') {
24             continue;
25         }
26         if (!cin || temp[0] == '\0') {
27             break;
28         }
29         else
30             saying[i] = temp;
31     }
32     int total = i;
33     
34     if (total > 0) {
35         cout << "Here are your satings:\n";
36         for (i = 0; i < total; i ++) {
37             cout << saying[i] << "\n";
38             
39         }
40         String *shortest = &saying[0];
41         String *first = &saying[0];
42         for (i = 1; i < total; i++) {
43             if (saying[i].lenth() < shortest->lenth()) {
44                 shortest = &saying[i];
45             }
46             if (saying[i] < *first) {
47                 first = &saying[i];
48             }
49             
50         }
51         cout << "Shortest saying:\n" << *shortest << endl;
52         cout << "First alphabetically:\n" << *first << endl;
53         srand(time(0));
54         int chioce = rand()%total;
55         String * favorite = new String(saying[chioce]);
56         cout << "My favorite saying :\n" << *favorite << endl;
57         delete favorite;
58     }
59     else
60         cout << "Not much to say,eh?\n";
61     cout << "Bye!\n";
62     return 0;
63 }

 

输出结果:

Hi,what's your name?
MuPaomiao
MuPaomiao,please enter up to10 short saying <empty line to quit>:
1:r
2:djifh
3:kfaodjfo
4:fsjdiofhisbnb
5:jfisdhio
6:ndfishdfo
7:
Here are your satings:
r
djifh
kfaodjfo
fsjdiofhisbnb
jfisdhio
ndfishdfo
Shortest saying:
r
First alphabetically:
r
My favorite saying :
fsjdiofhisbnb
Bye!

 

 

  使用new初始化对象:  

    通常,如果Class_name是类,value的类型为Type_name,则下面的语句:

      Class_name * pclass = new Class_name(value);

    将调用如下构造函数:

      Class_name(Type_name);

    这里可能还有一些琐碎的转换,例如:

       Class_name(const Type_name &);

    另外,如果不存在二义性,则将发生由原型匹配导致的转换(如从int到double)。下面的初始化方式将调用默认构造函数:

      Class_name * ptr = new Class_name;

  1、再谈new和delete

    如果类含有静态数据成员,在使用new为该类对象分配内存的时候,不会为给类的静态数据成员分配内存,这是因为静态数据成员独立于对象被保存。

    在下列情况下析构函数将被调用:

    *如果对象是动态变量,则当执行完定义该对象的程序块时,将调用该对象的析构函数。

    *如果对象时静态变量(外部、静态、静态外部或来自名称空间),则在程序结束时将调用对象的析构函数。

    *如果对象是new创建的,则仅当显式使用delete删除对象时,其析构函数才会被调用。

  2、指针和对象小结  

    *使用常规表示法来声明指向对象的指针:

        String * pStr;

    *可以将指针初始化为指向已有的对象:

        String *first = &saying[0];

    *可以使用new来初始化指针,这将创建一个新的对象:

        String *favorite = new String(saying[choice]);

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

        String * gleep = new String;//调用默认构造函数来初始化新建对象

        String * glop = new String("my");//将调用构造函数String(const char *)来初始化新建对象

        String *favorite = new String(saying[choice]);//将会调用构造函数String(const String &)来初始化新建对象

    *可以使用->运算符通过指针访问类方法:

        if(saying[i].lenth() < shortest->lenth())

    *可以对对象指针应用解除引用运算符(*)来获得对象:

        if(saying[i] < *first)

          first = &saying[i];

  3、再谈定位new运算符。

    定位new运算符让程序员能够在分配内存是能够指定内存位置。

    请看下面的程序,该程序使用定位new运算符和常规new运算符给对象分配内存,其中定义的构造函数和析构函数都会显示一些信息,让用户能够了解对象的历史。

 1 #include <iostream>
 2 #include <string>
 3 #include <new>
 4 
 5 using namespace std;
 6 const int BUF = 512;
 7 
 8 class JustTesting{
 9 private:
10     string words;
11     int number;
12 public:
13     JustTesting(const string &s = "Just Testing", int n = 0 ){
14         words = s;
15         number = n;
16         cout << "\"" << words << "\"构建出来了\n";
17     }
18     ~JustTesting(){
19         cout << "\"" << words << "\"被摧毁了\n";
20     }
21     void show()const{
22         cout << words << "," << number << endl;
23     }
24 };
25 
26 int main(int argc, const char * argv[]) {
27     char * buffer = new char[BUF];
28     
29     JustTesting *pc1,*pc2;
30     pc1 = new (buffer)JustTesting;//定位new运算符 31     pc2 = new  JustTesting("Heap1",20);
32     
33     cout << "内存地址:\n" << "buffer:" << (void *)buffer << "      heap:" << pc2 << endl;
34     cout << "内存内容:\n";
35     cout << pc1 << ":";
36     pc1->show();
37     cout << pc2 << ":";
38     pc2->show();
39     
40     JustTesting *pc3,*pc4;
41     pc3 = new (buffer)JustTesting("Bad Idea",6);
42     pc4 = new JustTesting("Heap2",10);
43     
44     cout << "内存内容:\n";
45     cout << pc3 << ":";
46     pc3->show();
47     cout << pc4 << ":";
48     pc4->show();
49     
50     delete pc2;
51     delete pc4;
52     delete []buffer;
53     cout << "完成\n";
54     return 0;
55 }

 

输出结果:

"Just Testing"构建出来了
"Heap1"构建出来了
内存地址:
buffer:0x100105630      heap:0x100105530
内存内容:
0x100105630:Just Testing,0
0x100105530:Heap1,20
"Bad Idea"构建出来了
"Heap2"构建出来了
内存内容:
0x100105630:Bad Idea,6
0x100200000:Heap2,10
"Heap1"被摧毁了
"Heap2"被摧毁了
完成

 

   说明:上面的程序使用new运算符创建了一个512字节的内存缓冲区,然后使用new运算符在堆中创建两个JustTesting对象,并试图用定位new运算符在内存缓冲区中创建两个JustTesting对象。最后,它使用delete来释放使用new分配的内存。

  上面的程序在使用定位new运算符时存在两个问题。首先,在创建第二个对象时,定位new运算符使用一个新的的对象覆盖用于第一个对象的内存单元。显然,如果类动态地为其成员分配内存,这将引发问题。

  其次,将delete用于pc2和pc4时,将调用pc2和pc4指向的对象的析构函数;然而,将delete[]用于buffer时,不会为使用定位new运算符创建的对象调用析构函数。

  第一个教训:程序员必须负责管理使用定位new运算符从内存缓冲区分配的内存单元。要使用不同的内存单元,程序员需要提供两个位于内存缓冲区的不同的地址,并确保这两个内存单元不重叠。例如,可以这样做:

      pc1 = new (buffer)JustTesting;

      pc3 = new (buffer + sizeof(JustTesting)) JustTesting("Better Idea", 6);

      其中指针pc3相对于pc1的偏移量为JustTesting对象的大小。

   第二个教训:如果使用定位new运算符来为对象分配内存,必须确保其析构函数被调用。对于在堆中创建的对象,可以这样做:

      delete pc2;

    但对于使用定位new运算符创建的对象,不能像下面这样做:

      delete pc1;

      delete pc3;

   原因在于,delete可以与常规new运算符配合使用,但是不能与定位new运算符配合使用。

  这种问题的解决方案是,显式地为使用定位new运算符创建的对象调用析构函数。正常情况下将自动调用析构函数,这是少数几种需要显式调用析构函数的情况之一。显式调用析构函数,需要制定要销毁的对象。由于有指向对象的指针,因此可以这样使用这些指针:

      pc1->~JustTesting();

      pc3->~JustTesting();

  需要注意的是删除顺序:对于使用new定位运算符创建的对象,应以与创建顺序相反的顺序进行删除。原因在于,完创建的对象可能依赖于早创建的对象。另外,仅当所有对象都被销毁后,才能释放用于存储这些对象的缓冲区。   

  下面是对上面的程序作出修改后的代码:

 1 #include <iostream>
 2 #include <string>
 3 #include <new>
 4 
 5 using namespace std;
 6 const int BUF = 512;
 7 
 8 class JustTesting{
 9 private:
10     string words;
11     int number;
12 public:
13     JustTesting(const string &s = "Just Testing", int n = 0 ){
14         words = s;
15         number = n;
16         cout << "\"" << words << "\"构建出来了\n";
17     }
18     ~JustTesting(){
19         cout << "\"" << words << "\"被摧毁了\n";
20     }
21     void show()const{
22         cout << words << "," << number << endl;
23     }
24 };
25 
26 int main(int argc, const char * argv[]) {
27     char * buffer = new char[BUF];//创建512字节的内存缓冲区
28     
29     JustTesting *pc1,*pc2;
30     pc1 = new (buffer)JustTesting;//使用定位new运算符在buffer的内存缓冲区上面为JustTesting对象分配内存
31     pc2 = new  JustTesting("Heap1",20);
32     
33     cout << "内存地址:\n" << "buffer:" << (void *)buffer << "      heap:" << pc2 << endl;
34     cout << "内存内容:\n";
35     cout << pc1 << ":";
36     pc1->show();
37     cout << pc2 << ":";
38     pc2->show();
39     
40     JustTesting *pc3,*pc4;
41     pc3 = new (buffer + sizeof(JustTesting))JustTesting("Bad Idea",6);//在内存缓冲区上为对象分配内存一定要避免重叠
42     pc4 = new JustTesting("Heap2",10);
43     
44     cout << "内存内容:\n";
45     cout << pc3 << ":";
46     pc3->show();
47     cout << pc4 << ":";
48     pc4->show();
49     
50     delete pc2;
51     delete pc4;
52     //在显式调用析构函数释放使用定位new运算符创建的对象的时候,释放的顺序应该与创建时的顺序相反,这是因为后创建的对象可能依赖于先创建的对象;最后,才能释放用于存储这些对象的内存缓冲区。
53     pc3->~JustTesting();//必须先释放在内存缓冲区上最后创建的对象
54     pc1->~JustTesting();
55     delete []buffer;
56     cout << "完成\n";
57     return 0;
58 }

 

输出结果:

"Just Testing"构建出来了
"Heap1"构建出来了
内存地址:
buffer:0x100105630      heap:0x100200000
内存内容:
0x100105630:Just Testing,0
0x100200000:Heap1,20
"Bad Idea"构建出来了
"Heap2"构建出来了
内存内容:
0x100105650:Bad Idea,6
0x100200020:Heap2,10
"Heap1"被摧毁了
"Heap2"被摧毁了
"Bad Idea"被摧毁了
"Just Testing"被摧毁了
完成

 

 

五、复习各种技术

  1、重载<<运算符

  要重新定义<<运算符,以便将他和cout一起使用来显示对象的内容,请定义下面的友元运算符函数:

    osteram & operator<<(osteram & os, const c_name & obj){

      os << ...;//显示对象的内容

      return os;

    }

  其中c_name是类名。如果改类提供了能够返回所需内容的公有方法,则可在运算符函数中使用这些方法,这样将不用将他们设置为友元函数了。

  2、转换函数

  要将单个值转换为类类型,需要创建原型如下的所示的类构造函数:

    c_name(type_name value);

  其中c_name为类名,type_name是要转换的类型的名称。

  要将类转换为其他类型,需要创建原型如下所示的类成员函数:

    operator type_name();

  虽然该函数没有声明返回类型,但应返回所需类型的值。

  使用转换函数时要小心。可以在声明构造函数时使用关键字explicit,以防止它被用于隐式转换。

  3、其构造函数使用new的类

  如果类使用new运算符来分配类成员指向的内存,在设计时应采取一些预防措施(前面总结了这些预防措施,应牢记这些规则,这是因为编译器并不知道这些规则,因此无法发现错误)。

    *对于指向的内存是由new分配的所有类成员,都应在类的析构函数中对其使用delete,该运算符将释放分配的内存。

    *如果析构函数通过对指针类成员使用delete来释放内存,则每个构造函数都应当使用new来初始化指针,或将它设置为空指针。

    *构造函数中要么使用new[],要么使用new,不能混用。如果构造函数使用的是new[],则析构函数中应使用delete[];如果构造函数使用的是new,则析构函数应使用delete。

    *应定义一个分配内存(而不是将指针指向已有内存)的复制构造函数。这样程序将能够将类对象初始化为另一个类对象。这种构造函数的原型通常如下:

        className(const className &);

    *应定义一个重载赋值运算符的类成员函数,其函数定义如下(其中c_pointer是c_name的类成员,类型为指向type_name的指针)。下面的示例假设使用new[]来初始化变量c_pointer:

        c_name & c_name::operator=(const c_name & cn){

          if(this == &cn){

            return *this;//如果将对象本身赋给自己

          }

          delete [] c_pointer;

          //分配内存给c_pointer,其大小为cn.c_pointer指向的内存块的大小

          c_pointer = new type_name[size];

          //然后,将cn.c_pointer指向的内存的内容复制到c_pointer指向的内存块中

          return *this;

        }

 

六、队列模拟

  队列是一种抽象的数据类型(Abstract Data Type,ADT),可以存储有序的项目序列。新项目被添加到队尾,并可以删除队首的项目。队列有点像栈,但栈在同一端添加或删除。这使得栈是一种后进先出(LIFO,last-in,first-out)的结构,而队列是先进先出(FIFO,first-in,first-out)的。

  1、队列类

  下面是队列的特征:

  *队列存储有序的项目序列;

  *队列所能容纳的项目数目是有一定限制的;

  *应当能够创建空队列;

  *应当能够检查队列是否为空;

  *应当能够检查队列是否是满的;

  *应当能够在队尾添加项目;

  *应当能够从队首删除项目;

  *应当能够确定队列中项目数。

  设计类时,需要开发公有接口和私有实现。

 

 (1)Queue类的接口

   从队列的特征可知,Queue类的公有接口应该如下:

    class Queue{

      enum{Q_SIZE = 10};

    private:

        //稍后扩展

    public:

       Queue(int qs = Q_SIZE);

       ~Queue();

       bool isempty() const;

       bool isfull() const;

       int queuecount() const;

       bool enqueue(const Item & item);

       bool dequeue(Item & item);

    };

    构造函数创建一个空队列。默认情况下,队列最多可存储10个项目,但是可以用显式初始化参数覆盖该默认值:

      Queue line1;//创建一个最多能存储10个项目的空队列

      Queue line2(20);//创建一个最多能存储20个项目的空队列

    使用队列时,可以使用typedef来定义Item。

  (2)Queue类的实现

    确定接口后,就可以实现队列了。首先,链表能够很好地满足队列的要求。链表由节点序列构成。每一个节点都包含要存储到链表中的信息以及一个指向下一个节点的指针。对于这里的队列来说,数据部分都是一个Item类型的值,因此可以使用下面的结构来表示节点:

    struct Node{

      Item item;

      struct Node * next;

    };

    在Queue类中,我们使用单链表。这样,知道了第一个节点的地址,就可以沿着指针找到后面的每一个节点。通常,链表的最后一个节点中的指针被置为空(NULL、0或nullptr,推荐使用nullptr),以指出后面没有节点了。因此,可以给Queue类设置一个数据成员指向链表的起始位置。然而,由于队列总是将新项目添加到队尾,因此包含一个指向最后一个节点的数据成员将非常方便。此外,还可以使用数据成员来跟踪队列的可存储的最大项目数以及当前的项目数。所以,类声明的私有部分与下面类似:

    class Queue{

        typedef struct Node{Item item; struct Node *next;}Node;

        enum{Q_SIZE = 10};

    private:

       Node *front;

       Node *rear;

       int items;

       const int qsize;

       ...

    public:

      ...

    };

    上述声明中使用了C++的一项特性:在类中嵌套结构或类声明。

    补充:嵌套结构和类

      在类声明中声明的结构、类或枚举被称为是被嵌套在类中,其作用域为整个类。这种声明不会创建数据对象,而只是指定了可以在类中使用的类型。如果声明是在类的私有部分进行的,则只能在这个类使用声明的类型;如果声明是在公有部分进行的,则可以从类的外部通过作用域解析运算符使用被声明的类型。例如,如果Node是在Queue类的公有部分声明的,则可以在类的外面声明Queue::Node类型的变量。

  (3)类的常量数据成员的初始化

   在Queue类中,有一个数据成员(qsize)被声明为常量,那么下面的构造函数实现方法将无法正常运行:

      Queue::Queue(int qs){

        front = rear = nullptr;//由于在这个例子中,队列最初是空的,因此队首和队尾指针都设置为空。

        items = 0;

        qsize = qs;//该语句将无法正常运行

      }

    问题在于qsize是常量,所以可以对它进行初始化,但不能给他赋值。从概念上讲,调用构造函数时,对象将在括号中的代码执行之前被创建。因此,调用Queue(int qs)构造函数将导致程序首先给4个成员变量分配内存。然后,程序进入到括号中,使用常规的赋值方式将值存储到内存中。因此,对于const数据成员,必须在执行到构造函数体之前,即创建对象时进行初始化。C++提供了一种特殊的语法来完成上述工作,它叫做成员初始化列表。下面对成员初始化列表语法进行详细阐述。

    成员初始化列表语法:

      如果Classy是一个类,mem1、mem2和mem3都是这个类的数据成员,则类构造函数可以使用如下的语法来初始化数据成员:

          Classy::Classy(int n, int m):mem1(n),mem2(0),mem3(n*m + 2){

            ...

          }

      上述代码将mem1初始化为n,将mem2初始化为0,将mem3初始化为n*m  + 2。从概念上说,这些初始化工作实在对象创建时完成的,此时还未执行括号中的任何代码。但是,请注意一下几点:

    *这种格式只能用于构造函数;

    *必须用这种格式来初始化非静态const数据成员;

    *必须用这种格式来初始化引用数据成员。

    数据成员的初始化的顺序与他们出现在类声明中的顺序相同,与初始化其中排列顺序无关。

    警告:不能将数据初始化列表语法用于狗哦早函数之外的其他方法。

    成员初始化列表使用的括号方式也可以用于常规初始化,例如:

      int games = 162;

      double talk = 2.44;

      替换为:

      int games(162);

      double talk(2.44);

    这使得初始化内置类型就像初始化对象一样。

    C++11的类内初始化:

    C++11允许以更直观的方式进行初始化:

        class Classy{

          int mem1 = 10;

          const int mem2 = 20;

          .....

        };

    这与在构造函数中使用成员初始化列表等价:

      Classy::Classy():mem1(10),mem2(20){...}

    成员mem1和mem2将分别被初始化为10和20,除非调用了使用成员初始化列表的构造函数,在这种情况下,实际列表将覆盖这些默认初始值:

      Classy::Classy(int n):mem1(n) {...}

    在这里,构造函数将使用n来初始化mem1,但mem2仍被设置为20.

       下面是类Queue的具体设计与类方法实现:

Queue.h

 1 #include <iostream>
 2 typedef int Item;//在这里,typedef来声明一个别称,共后面设计Queue类时使用。
 3 using namespace std;
 4 class Queue{
 5     typedef struct Node{
 6         Item item;
 7         struct Node * next;
 8     }Node;
 9     enum{Q_SIZE = 10};
10 private:
11     Node * front;//队首
12     Node * rear;//队尾
13     int items;//项目数目
14     const int qsize;//队列长度,为常量
15 public:
16     Queue(int qs = Q_SIZE);
17     Queue(const Queue &);//这里需要提供一个复制构造函数,不然将会造成严重后果
18     ~Queue();
19     bool isempty()const;
20     bool isfull()const;
21     int queuecount()const;
22     bool enqueue(const Item & item);
23     bool dequeue(Item &item);
24     Queue & operator=(const Queue &);//同时,也必须提供一个赋值运算符
25     friend  ostream & operator<<(ostream & os, const Queue & q);
26 };

 

 

Queue.cpp

  1 #include "Queue.h"
  2 Queue::Queue(int qs):qsize(qs){//在这里需要使用成员初始化列表语法来初始化类的常量数据成员
  3     front = rear = nullptr;
  4     items = 0;
  5 }
  6 Queue::Queue(const Queue & q):qsize(q.qsize){
  7     items = q.items;
  8     if (q.front == nullptr) {
  9         front = rear = nullptr;
 10         return;
 11     }
 12     Node *temp = q.front;
 13     Node *temp1 = front = new Node{temp->item,nullptr};
 14     temp = temp->next;
 15     while (temp != nullptr) {
 16         temp1->next = new Node{temp->item,nullptr};
 17         temp1 = temp1->next;
 18         temp = temp->next;
 19     }
 20     rear = temp1;
 21     
 22 }
 23 Queue::~Queue(){//虽然在构造函数中没有使用new,但是向队列添加节点的时候将调用new来创建新节点。通过删除节点的方式,dequeue()方法确实可以删除节点,但这并不能保证在队列到期时为空。因此,必须提供一个显式的析构函数来删除队列中剩余的节点
 24     Node *ptr = front;
 25     while (front != nullptr) {
 26         front = front->next;
 27         delete ptr;
 28         ptr = front;
 29     }
 30    rear = front = nullptr;
 31 }
 32 bool Queue::isempty()const{
 33     if (items == 0) {
 34         return true;
 35     }
 36     return false;
 37 }
 38 bool Queue::isfull()const{
 39     if (items == qsize) {
 40         return true;
 41     }
 42     return false;
 43 }
 44 int Queue::queuecount()const{
 45     return items;
 46 }
 47 bool Queue::enqueue(const Item &item){
 48     if (isfull()) {//如果队列已满,则结束
 49         return false;
 50     }
 51     
 52     Node *add = new Node{item,nullptr};//新建一个节点,并在节点中放入正确的值
 53     if (items == 0) {//如果队列为空,让队首和队尾都指向新建的节点,那么他既是队首也是队尾
 54         front = rear = add;
 55     }
 56     else
 57         rear->next = add;//把新建的节点添加到链表的结尾
 58     rear = add;//让队尾指针指向最后一个节点
 59     items++;//将项目数加1
 60     return true;
 61 }
 62 bool Queue::dequeue(Item &item){
 63     if (front == nullptr) {//如果队列为空,则结束
 64         return false;
 65     }
 66     item = front->item;//将队列的第一个项目提供给调用函数,这是通过将当前front节点中的数据部分复制到传递到方法中的引用变量中来实现
 67     items--;//将项目数减1
 68     Node *temp = front;//保存front节点的位置,供以后删除,这一步必不可少
 69     front = front->next;//让节点出队。这是通过将Queue的成员指针front设置为指向下一个节点来完成的,该节点的位置由front->next提供
 70     delete temp;//为节省内存空间,删除以前的第一个节点
 71     if (items == 0) {//如果链表为空,将rear设置为nullptr
 72         rear = nullptr;
 73     }
 74     return true;
 75 }
 76 ostream & operator<<(ostream & os, const Queue &q){
 77     if (q.front == nullptr) {
 78         os << "null\n";
 79         return os;
 80     }
 81     auto ptr = q.front;
 82     while (ptr != nullptr) {
 83         os << ptr << ":" << ptr->item << endl;
 84         ptr = ptr->next;
 85     }
 86     return os;
 87 }
 88 Queue & Queue::operator=(const Queue & q){
 89     if (this == &q) {
 90         return *this;
 91     }
 92     while (items != 0) {
 93         Item item;
 94         dequeue(item);
 95     }
 96     
 97     items = q.items;
 98     if (q.front == nullptr) {
 99         front = rear = nullptr;
100         return *this;
101     }
102     
103     Node *temp = q.front;
104     Node *temp1 = front = new Node{temp->item,nullptr};
105     temp = temp->next;
106     while (temp != nullptr) {
107         temp1->next = new Node{temp->item,nullptr};
108         temp1 = temp1->next;
109         temp = temp->next;
110     }
111     rear = temp1;
112     return *this;
113     
114 }

  问题补充:在上面的,没有显式提供复制构造函数和赋值运算符重载函数。虽然,编译器会提供默认的复制构造函数和默认的赋值运算符重载函数,但是可以确定的是默认的复制构造函数默认的赋值运算符是绝对能不能用的。使用默认的复制构造函数和默认的赋值运算符的一个共同问题是,复制Queue对象将生成一个新的对象,而新对象指向链表原来的头和尾,因此对新对象进行修改将会影响到原来的对象,这是不允许的。因此,需要提供一个深度复制的复制构造函数和深度复制的赋值运算符重载函数。提供深度复制的复制构造函数是没有问题的;但是,提供深度复制的赋值运算符重载函数存在一个新的问题。因为在Queue类中有一个常量数据成员,将一个Queue对象赋值给另一个已存在的Queue对象的时候,被赋值对象的常量数据成员的值是不会发生改变的,它仍然会保持原来的值不变(常量只能初始化,不能被赋值)。这样,将一个Queue对象赋值给另一个Queue对象是不完整的,甚至会导致程序错误。但是,编译器会为类提供默认的复制构造函数和默认的赋值运算符重载函数(如果没有显示提供复制构造函数和赋值运算符重载函数),为了避免在编程的时候导致隐形错误,我们最好是显示提供复制构造函数,而将赋值运算符禁用,或者将两者都禁用。

  将所需方法禁用的方式有两种。一种是将所需的方法定义为伪私有方法:

      class Queue{

      private:

         Queue(const Queue & q):qsize(0){}//将复制构造函数定义为私有方法,这样编译器就不会提供默认复制构造函数,并且也不能被广泛使用

         Queue & operatro=(const Queue & q){return *this} //将赋值运算符重载函数定义为私有方法,这样编译器就不会提供默认赋值运算符重载函数,也不能被广泛使用

      public:

         ...

      };

  这样做有两个作用:第一,它避免了本来将自动生成的默认方法定义。第二,因为这些方法是私有的,所以不能被广泛使用。也就是说,如果nip和tuck是Queue对象,则编译器不允许这样做:

        Queue snick(nip);//不允许

        tuck = nip;//不允许

  第二种方式,是使用C++11提供的关键字delete,这将在后面介绍。

  接下来我们对Queue进行完善,下面的代码为Queue类提供了复制构造函数的定义,并将复制运算符禁用:

Queue.h的补充:

1 class Queue{
2 private:
3           Queue & operator=(const Queue & q){return *this}//将赋值运算符禁用
4 public:
5           Queue(const Queue &);//重定义复制构造函数,避免自动生成的默认复制构造函数
6 };

 

Queue.cpp的补充:

 1 Queue::Queue(const Queue & q):qsize(q.qsize){//使用成员初始化列表初始化常量数据成员
 2     items = q.items;
 3     if (q.front == nullptr) {//如果用来初始化的队列是空队列,就直接将新对象初始化为空队列
 4         front = rear = nullptr;
 5         return;
 6     }
 7     //如果用来初始化的队列不是空队列,就需要进行深度复制  
 8     Node *temp = q.front;
 9     Node *temp1 = front = new Node{temp->item,nullptr};
10     temp = temp->next;
11     while (temp != nullptr) {
12         temp1->next = new Node{temp->item,nullptr};
13         temp1 = temp1->next;
14         temp = temp->next;
15     }
16     rear = temp1;
17     
18 }