有效创建一个类(一)

    技术2022-07-04  147

    Bjarne Stroustrup的大作《The C++ Programming Language》

    类层次结构的基础当然是如何有效地定制一个类;第十章第三节做了如下的描述: 

    引用 1. 构造函数【与析构函数】(方括号部分是我自己加的)  2. 一组类成员查看函数(const标记)  3. 一组类成员操作函数,当然这部分也包括运算符重载,使得操作起来感觉更自然  4. 一组隐式定义的函数,可以使定义类自由地复制(拷贝构造函数和拷贝赋值操作)  5. 一个与该定义相关的异常类,用于通过异常类报告或者处理出错的情况

    有效地这样的一个描述针对设计一个类显然具有典型意义,但是类定制与类的设计并不仅限于此。在没有考虑更深的类层次结果设计之前,上述的每一步都值得探究一番。 1. 构造函数【与析构函数】 除了考虑默认构造函数,成员应该如何初始化以及如何销毁之外,应该还需要考虑这个类实例对象的建立方式与对应的销毁方式。那么到底有哪些建立及相应的销毁方式呢?Bjarne Stroustrup给出如下几种方式:

    引用 A. 一个命名的自动对象,每次程序执行到其声明时建立、程序离开它所出现的块时销毁;  B. 一个堆对象,通过new建立,通过delete销毁(注意:一定要成对使用,如果在调用new时,有中括号,相应地delete必须加中括号。简单地讲,用delete销毁数组时,会有内存泄漏产生,那么用delete[]销毁一个非数组对象时,更会招致内存泄漏或“不确定性为”更大的麻烦。  C. 一个非静态成员对象,作为另一个类对象成员,在它作为成员的那个对象建立或销毁时,它随之被建立和销毁  D. 一个数组元素,在它作为元素的那个数组被建立和销毁的时候建立和销毁;  E. 一个局部静态对象,在程序执行第一次遇到它的声明时建立一次,在程序终止时销毁一次;(注意,STATIC关键字的语义,一旦静态变量被创建,其生命周期与程序生命周期相同)  F. 一个全局对象、名字空间对象,类静态对象,它们只在“程序开始时”建立一次,在程序终止时销毁一次。(这里需要引起注意的是,静态变量的跨编译单元的初始化次序是未定义的,因此《Effective C++》给出的建议是用局部静态对象替换非局部静态对象。)  G. 一个临时对象,作为表达式求值的一部分被建立,在它所出现的那个完整表达式的最后被销毁。通常情况下,我们尽量避免临时对象的产生,要知道浪费在临时对象的创建与销毁的开销对于那些性能要求严格的应用程序来说,的确不是什么好的主意。  H. 一个在分配操作中由所提供的参数控制,在通过用户提供的函数获得的存储里放置的对象。  I. 一个union成员,它不能有构造函数和析构函数。

    在进行详细设计时,必须考虑到类对象与类对象之间的耦合关系或者依赖关系,因为这些关系在很大程度上决定了这种类建立或者销毁的方式。 针对A方式来说,这种方式能够帮助我们设计管理资源的类,因为通过这种方式即使在抛出异常的情况下,也能释放掉类管理的资源。而利用A方式的技巧,C++赋予了一个非常好听的名字“资源获得即初始化”(Resource Acquisition Is Initialization: RAII)。当然方式的利用方式的具体表现形式C++提供了大致两种: i.  创建auto_ptr, std::tr1::shared_ptr<T>; ii. 创建管理资源对应的类,如: 

         C++代码  class File_ptr{      FILE* p;  public:      File_ptr(const char* n, const char *a) { p = fopen(n,a); }      File_ptr(FILE *pp) { p = pp; }      ~File_ptr(){ fclose(p); }        operator FILE*() { return p; } //函数调用  };  

    一个类的构造函数创建时,还需要进一步考虑如何初始化成员变量和初始化的次序;建议使用成员初始化列表,为什么?这里的主要原因是因为拷贝构造函数和拷贝复制操作的语义引起的;简单地看下面的代码: 

    C++代码  #include <iostream>  #include <string>  #include <list>    using namespace std;    class PhoneNumber { ... };  class A {  public:     A(const string& name, const string& address, const list<PhoneNumber>& phones);  private:    string theName;    string theAddress;    list<PhoneNumber> thePhones;    int numTimesConsulted;  }  A::A(const string& name, const string& address, const list<PhoneNumber>& phones)  {     theName           = name;     theAddress        = address;     thePhones         = phones;     numTimesConsulted = 0;  }   

    上面的这个构造函数定义简直再熟悉不过,当然A对象会带有你期望初始化的值,但不是最佳做法。为什么呢?实际上,在A构造函数内,theName, theAddress和thePhones都不是被初始化,而是被赋值。初始化发生的时间更早,发生于这些成员的默认构造函数调用之时。所以这些成员先是通过默认构造函数创建并初始化,然后通过A构造函数的实参进行拷贝操作完成赋值操作的。如果通过成员初始化列表的方式来进行的话,就等同于直接将实参传递给各成员的构造函数进行创建并初始化的工作。因此,通常情况下,后者的效率远远高于前者。 在成员初始化列表中的成员初始化的次序可以任意指定,但是构造函数不会理会这个你指定的次序,而总是按照类变量在其类声明中的次序依次进行。 在上面的描述中,我提及到了默认构造函数,好吧,C++真的是帮助开发人员做了许多内部工作。如果任何一个类在声明时并未构造任何实际的构造函数的话,C++会替我们创建一个编译器产生的无参构造函数,这个构造函数就被称为默认构造函数。一旦类声明中包含有其他带有参数的构造函数,默认函数就不会被创建了。这个原因很简单,编译器通过读取类声明,知道它如果再帮你产生这样一个默认构造函数无异于画蛇添足。除了默认构造函数外,C++编译器还会帮我们默认创建拷贝构造函数,拷贝赋值操作和析构函数,如果这些都没有在类声明中声明的话。针对拷贝构造函数、拷贝赋值操作和析构函数,我会在下面讲解中更详细的描述我的总结。 拷贝构造函数与拷贝赋值操作 

    C++代码  class Order {  public:    Order();                // default contructor    Order(const Order& _o); // copy constructor    Order& operator=(const Order& _o); //copy assignment operator    ...   };    Order o1;      // call default constructor  Order o2(o1);  // call copy constructor  o1 = o2;       // call copy assignment operator  Order o3 = o2; // call copy constructor  

    幸运的是,这两者还是可以区别开来的,虽然看上去感觉很容易迷惑。如果一个新对象被定义(例如上述语句中的o3),一定会有个构造函数被调用,不可能调用赋值操作,如果没有新对象被定义(例如上述语句中o1=o2),就不会有构造函数被调用,那么当然就是赋值操作被调用。 拷贝构造函数在C++语言中绝对需要引起注意,因为这个函数如果稍微不加注意,便会引起不必要的麻烦。通常编译器产生的拷贝构造函数和拷贝赋值操作都是浅拷贝。所谓浅拷贝的语义是memwise copy。简单点说,会复制类对象中的每一个成员。如果类声明中,只是包含一些简单的内置类型,如int,double等,浅拷贝的语义是正确的。但是一旦类中涉及指针变量或者引用变量,浅拷贝的语义就是简单地拷贝指针的内容而不会拷贝指针(引用)所指向(引用)的内容。 看起来浅拷贝的语义是正确的!难道这种语义针对指针成员变量或者引用成员变量,会引起什么问题吗?当然会引起问题,而且引起的问题不小。看下面的代码: 

    C++代码  class Name{   const char *s;   // ..  };  class Table {    Name *p;    size_t sz;  public:    Table(size_t s=15) { p = new Name[sz=s]; }    ~Table() {delete[] p;}    Name* lookup(const char *);    bool insert(Name*);  };  void h()  {     Table t1;     Table t2 = t1;      Table t3;          t3 = t2;  }  

    观察上述代码,t2=t1调用了Table的拷贝构造函数,由于p是一个Name指针,所以t2当中的p会指向t1对象中p所指向的Name(默认为15个大小的数组),这个数组中的内容不会被拷贝两份,当t1对象和t2对象相继被销毁时,Table的析构函数会相继被调用两次,那么delete[]就会再次试图销毁已经被销毁的Name数组。这就会使程序运行到一个不确定的行为,后果很严重。所以,如果类声明中包含了指针(引用)成员变量时,建议开发人员视情况自己定义拷贝构造函数与拷贝赋值操作。 有时候我们对C++编译器默认产生这些构造函数的帮助实在是盛情难却,但的确它们的产生又给程序造成了影响。当然,没有冒犯编译器大人的意思,那么请用private修饰拷贝构造函数和拷贝赋值操作吧,这样就表示你给编译器一个明显的信号,告诉它不要为我的类产生这两个函数了。注意,上面的这句话确切说是有点错误的。不是编译器不自动产生,还是会自动产生的,但是通过private修饰,可以成功地组织别人调用它)。当然这么做,也不绝对安全,为什么呢?因为一些friend类或者成员函数仍然可以访问这个类的私有成员。不过,到现在为止,还没有什么其他更好的办法。如果你看到了更好的办法,一定要告诉我! 析构函数 析构函数相对来说注意的问题相对来说简单,总结呢主要有两个: 1. 多态基类声明析构函数时,一定要加virtual修饰 2. 析构函数中一定要捕获所有异常,不要让异常逃离析构函数 3. 针对构造函数和析构函数,不要在调用过程中使用virtual函数。 上述三个注意事项请参考《Effective C++》 

     


    最新回复(0)