值的一读的C++精品文章:论C++构造函数中的不合理设计

类别:VC语言 点击:0 评论:0 推荐:

 



论C++构造函数中的不合理设计
作者:张岩  


  在C++中,构造函数是一个在构件对象的时候调用的特殊的函数,其目的是对对象进行初始化的工作,从而使对象被使用之前可以处于一种合理的状态。但是,构造函数的设计并不完美,甚至有些不合理的特性。比如说,限定构造函数名称与类的名称相同的条件。这些特性在构造C++编译器的时候是值得引起注意的。还有,在今后C++的标准修订或者制定其他面向对象的设计语言时候应当避免这些特性。这里也提出了一些解决的方案。
  C++中,任何类都有一个(至少有一个)构造函数,甚至在没有构造函数被声明的时候亦是如此。在对象被声明的时候,或者被动态生成的时候,这些构造函数就会被调用。构造函数做了许多不可见的工作,即使构造函数中没有任何代码,这些工作包括对对象的内存分配和通过赋值的方式对成员进行初始化。构造函数的名称必须与类的名称相同,但是可以有许多不同的重载版本来提供,通过参数类型来区分构造函数的版本。构造函数可以显式的通过用户代码来调用,或者当代码不存在是通过编译程序来隐式插入。当然,显式地通过代码调用是推荐的方法,因为隐式调用的效果可能不是我们所预料的,特别是在处理动态内存分配方面。代码通过参数来调用唯一的构造函数。构造函数没有返回值,尽管在函数体中可以又返回语句。每个构造函数可以以不同的方式来实例化一个对象,因为每个类都有构造函数,至少也是缺省构造函数,所以每个对象在使用之前都相应的使用构造函数。构造函数的调用如图1所示。

图1. The activities involved in the execution of a constructor

  因为构造函数是一种函数,所以他的可见性无非是三种public、private、protected。通常,构造函数都被声明为public型。如果构造函数被声明为private或protected,就限制了对象的实例化。这在阻止类被其他人实例化的方面很有效。构造函数中可以有任何C++的语句,比如,一条打印语句,可以被加入到构造函数中来表明调用的位置。


构造函数的类型

  C++中构造函数有许多种类型,最常用的式缺省构造函数和拷贝构造函数,也存在一些不常用的构造函数。下面介绍了四种不同的构造函数。

1、缺省构造函数
  缺省构造函数是没有参数的函数。另外,缺省构造函数也可以在参数列表中以参数缺省值的方式声明。缺省构造函数的作用是把对象初始化为缺省的状态。如果在类中没有显式定义构造函数,那么编译器会自动的隐式创建一个,这个隐式创建的构造函数和一个空的构造函数很相像。他除了产生对象的实例以外什么工作都不做。在许多情况下,缺省构造函数都会被自动的调用,例如在一个对象被声明的时候,就会引起缺省构造函数的调用。

2、拷贝构造函数
  拷贝构造函数,经常被称作X(X&),是一种特殊的构造函数,他由编译器调用来完成一些基于同一类的其他对象的构件及初始化。它的唯一的一个参数(对象的引用)是不可变的(因为是const型的)。这个函数经常用在函数调用期间于用户定义类型的值传递及返回。拷贝构造函数要调用基类的拷贝构造函数和成员函数。如果可以的话,它将用常量方式调用,另外,也可以用非常量方式调用。
  在C++中,下面三种对象需要拷贝的情况。因此,拷贝构造函数将会被调用。
  1). 一个对象以值传递的方式传入函数体
  2). 一个对象以值传递的方式从函数返回
  3). 一个对象需要通过另外一个对象进行初始化
  以上的情况需要拷贝构造函数的调用。如果在前两种情况不使用拷贝构造函数的时候,就会导致一个指针指向已经被删除的内存空间。对于第三种情况来说,初始化和赋值的不同含义是构造函数调用的原因。事实上,拷贝构造函数是由普通构造函数和赋值操作赋共同实现的。描述拷贝构造函数和赋值运算符的异同的参考资料有很多。
  拷贝构造函数不可以改变它所引用的对象,其原因如下:当一个对象以传递值的方式传一个函数的时候,拷贝构造函数自动的被调用来生成函数中的对象。如果一个对象是被传入自己的拷贝构造函数,它的拷贝构造函数将会被调用来拷贝这个对象这样复制才可以传入它自己的拷贝构造函数,这会导致无限循环。
  除了当对象传入函数的时候被隐式调用以外,拷贝构造函数在对象被函数返回的时候也同样的被调用。换句话说,你从函数返回得到的只是对象的一份拷贝。但是同样的,拷贝构造函数被正确的调用了,你不必担心。
  如果在类中没有显式的声明一个拷贝构造函数,那么,编译器会私下里为你制定一个函数来进行对象之间的位拷贝(bitwise copy)。这个隐含的拷贝构造函数简单的关联了所有的类成员。许多作者都会提及这个默认的拷贝构造函数。注意到这个隐式的拷贝构造函数和显式声明的拷贝构造函数的不同在于对于成员的关联方式。显式声明的拷贝构造函数关联的只是被实例化的类成员的缺省构造函数除非另外一个构造函数在类初始化或者在构造列表的时候被调用。
  拷贝构造函数是程序更加有效率,因为它不用再构造一个对象的时候改变构造函数的参数列表。设计拷贝构造函数是一个良好的风格,即使是编译系统提供的帮助你申请内存默认拷贝构造函数。事实上,默认拷贝构造函数可以应付许多情况。

3、用户定义的构造函数
  用户定义的构造函数允许对象在被定义的时候同时被初始化。这种构造函数可以有任何类型的参数。一个用户定义的和其它类型的构造函数在类 mystring 中得以体现:

  class mystring
  {......
  public: mystring(); // Default constructor
  mystring (mystring &src)
  // Copy constructor
  mystring (char * scr);
  // Coercion constructor
  mystring ( char scr[ ], size_t len);
  // User-Defined constructor

  };


4、强制构造函数
  C++中,可以声明一个只有一个参数的构造函数来进行类型转换。强制构造函数定一个从参数类型进行的一个类型转换(隐式的或显式的)。换句话说,编译器可以用任何参数的实例来调用构造函数。这样做的目的是建立一个临时实例来替换一个参数类型的实例。注意标准新近加入C++的关键字explicit 是用来禁止隐式的类型转换。然而,这一特性还没能被所有的编译器支持。下面是一个强制构造函数的例子:


  class A
  {
  public :
  A(int ){ }
  };
  void f(A) { }
  void g()
  {
  A My_Object= 17;
  A a2 = A(57);
  A a3(64);
  My_Object = 67;
  f(77);
  }

  像A My_Object= 17;这种声明意味着A(int)构造函数被调用来从整型变量生成一个对象。这样的构造函数就是强制构造函数。


普遍特性

  下面是一些C++构造函数的不合理设计,当然,可能还有其他一些不合理之处。但是,大多数情况下,我们还是要和这些特性打交道,我们要逐一说明。

1、构造函数可以为内联,但不要这样做
  一般来讲,大多数成员函数都可以在前面加入"inline"关键字而成为内联函数,构造函数也不例外,但是别这么做!一个被定义为内联的构造函数如下:

  class x
  {..........
  public : x (int );
  :
  :
  };
  inline x::x(int )
  {...}

  在上面的代码中,函数并不是作为一个单独的实体而是被插入到程序代码中。这对于只有一两条语句的函数来说会提到效率,因为这里没有调用函数的开销。
  用内联的构造函数的危险性可以在定义一个静态内联构造函数中体现。在这种情况下,静态的构造函数应当是只被调用一次。然而,如果头文件中含有静态内联构造函数,并被其他单元包括的话,函数就会产生多次拷贝。这样,在程序启动时就会调用所有的函数拷贝,而不是程序应当调用的一份拷贝。这其中的根本原因是静态函数是在以函数伪装下的真实对象。
  应该牢记的一件事是内联是建议而不是强制,编译器产生内联代码。这意味着内联是与实现有关的编译器的不同可能带来很多差异。另一方面,内联函数中可能包括比代码更多的东西。构造函数被声明为内联,所有包含对象的构造函数和基类的构造函数都需要被调用。这些调用是隐含在构造函数中的。这可能会创建很大的内联函数段,所以,不推荐使用内联的构造函数。

2、构造函数没有任何返回类型
  对一个构造函数指定一个返回类型是一个错误,因为这样会引入构造函数的地址。这意味着将无法处理出错。这样,一个构造函数是否成功的创建一个对象将不可以通过返回之来确定。事实上,尽管C++的构造函数不可以返回,也有一个方法来确定是否内存分配成功地进行。这种方法是内建在语言内部来处理紧急情况的机制。一个预定好的函数指针 new-handler,它可以被设置为用户定制的对付new操作符失败的函数,这个函数可以进行任何的动作,包括设置错误标志、重新申请内存、退出程序或者抛出异常。你可以安心的使用系统内建的new-handler。最好的使构造函数发出出错信号的方法,就是抛出异常。在构造函数中抛出异常将清除错误之前创建的任何对象及分配的内存。
  如果构造函数失败而使用异常处理的话,那么,在另一个函数中进行初始化可能是一个更好的主意。这样,程序员就可以安全的构件对象并得到一个合理的指针。然后,初始化函数被调用。如果初始化失败的话,对象直接被清除。

3、构造函数不可以被声明为static
  C++中,每一个类的对象都拥有类数据成员的一份拷贝。但是,静态成员则没有这样而是所有的对象共享一个静态成员。静态函数是作用于类的操作,而不是作用在对象上。可以用类名和作用控制操作符来调用一个静态函数。这其中的一个例外就是构造函数,因为它违反了面向对象的概念。
  关于这些的一个相似的现象是静态对象,静态对象的初始化是在程序的一开始阶段就进行的(在main()函数之前)。下面的代码解释了这种情况。

  MyClass static_object(88, 91);

  void bar()
  {
  if (static_object.count( ) > 14) {
  ...
  }
  }

  在这个例子中,静态变量在一开始的时候就被初始化。通常这些对象由两部分构成。第一部分是数据段,静态变量被读取到全局的数据段中。第二部分是静态的初始化函数,在main()函数之前被调用。我们发现,一些编译器没有对初始化的可靠性进行检查。所以你得到的是未经初始化的对象。解决的方案是,写一个封装函数,将所有的静态对象的引用都置于这个函数的调用中,上面的例子应当这样改写。

  static MyClass* static_object = 0;

  MyClass*
  getStaticObject()
  {
  if (!static_object)
  static_object =
  new MyClass(87, 92);
  return static_object;
  }

  void bar()
  {
  if (getStaticObject()->count( ) > 15)
  {
  ...
  }
  }


4、构造函数不能成为虚函数
  虚构造函数意味着程序员在运行之前可以在不知道对象的准确类型的情况下创建对象。虚构造函数在C++中是不可能实现的。最通常遇到这种情况的地方是在对象上实现I/O的时候。即使足够的类的内部信息在文件中给出,也必须找到一种方法实例化相应的类。然而,有经验的C++程序员会有其他的办法来模拟虚构造函数。
  模拟虚函数需要在创建对象的时候指定调用的构造函数,标准的方法是调用虚的成员函数。很不幸,C++在语法上不支持虚构造函数。为了绕过这个限制,一些现成的方法可以在运行时刻确定构件的对象。这些等同于虚构造函数,但是这是C++中根本不存在的东西。
  第一个方法是用switch或者if-else选择语句来手动实现选择。在下面的例子中,选择是基于标准库的type_info构造,通过打开运行时刻类型信息支持。但是你也可以通过虚函数来实现RTTI

  class Base
  {
  public:
  virtual const char* get_type_id() const;
  staticBase* make_object
  (const char* type_name);
  };

  const char* Base::get_type_id() const
  {
  return typeid(*this).raw_name();
  }

  class Child1: public Base
  {
  };

  class Child2: public Base
  {
  };

  Base* Base::make_object(const char* type_name)
  {
  if (strcmp(type_name,
  typeid(Child1).raw_name()) == 0)
    return new Child1;
  else if (strcmp(type_name,typeid
    (Child2).raw_name()) == 0)
  return new Child2;
  else
  {
   throw exception
  ("unrecognized type name passed");
  return 0X00; // represent NULL
    }
  }
  
  这一实现是非常直接的,它需要程序员在main_object中保存一个所有类的表。这就破坏了基类的封装性,因为基类必须知道自己的子类。
  一个更面向对象的方法类解决虚构造函数叫做标本实例。它的基本思想是程序中生成一些全局的实例。这些实例只再虚构造函数的机制中存在:

  class Base
  {
  public:
    staticBase* make_object(const char* typename)
  {
   if (!exemplars.empty())
   {
    Base* end = *(exemplars.end());
    list<Base*>::iterator iter =
    exemplars.begin();
    while (*iter != end)
     {
      Base* e = *iter++;
      if (strcmp(typename,
        e->get_typename()) == 0)
        return e->clone();
     }
   }
    return 0X00 // Represent NULL;
  }
  virtual ~Base() { };
  virtual const char* get_typename() const
  {
   return typeid(*this).raw_name();
  }
  virtual Base* clone() const = 0;
  protected:
  static list<Base*> exemplars;
  };
  list<Base*> Base::exemplars;
  // T must be a concrete class
  // derived from Base, above
  template<class T>
  class exemplar: public T
  {
   public:
   exemplar()
   {
    exemplars.push_back(this);
   }
  ~exemplar()
   {
   exemplars.remove(this);
   }
  };
  class Child: public Base
  {
   public:
   ~Child()
    {
    }
   Base* clone() const
   {
    return new Child;
   }
  };
  exemplar<Child> Child_exemplar;

  在这种设计中,程序员要创建一个类的时候要做的是创建一个相应的exampler<T>类。注意到在这个例子中,标本是自己的标本类的实例。这提供了一种高校得实例化方法。

5、创建一个缺省构造函数
  当继承被使用的时候,却省构造函数就会被调用。更明确地说,当继承层次的最晚层的类被构造的时候,所有基类的构造函数都在派生基类之前被调用,举个例子来说,看下面的代码:

  #include<iostream.h>
  class Base
  {
   int x;
   public :
      Base() : x(0) { } // The NULL constructor
      Base(int a) : x(a) { }
  };
  class alpha : virtual public Base
   {
   int y;
   public :
   alpha(int a) : Base(a), y(2) { }
   };
  class beta : virtual public Base
   {
   int z;
   public :
   beta(int a) : Base(a), z(3) { }
   };
  class gamma : public alpha, public beta
   {
    int w;
    public :
       gamma ( int a, int b) : alpha(a), beta(b), w(4) { }
   };
  main()
  {.....
  }


  在这个例子中,我们没有在gamma的头文件中提供任何的初始化函数。编译器会为基类使用缺省的构造函数。但是因为你提供了一个构造函数,编译器就不会提供任何缺省构造函数。正如你看到的这段包含缺省构造函数的代码一样,如果删除其中的缺省构造函数,编译就无法通过。
  如果基类的构造函数中引入一些副效应的话,比如说打开文件或者申请内存,这样程序员就得确保中间基类没有初始化虚基类。也就是,只有虚基类的构造函数可以被调用。
  虚基类的却省构造函数完成一些不需要任何依赖于派生类的参数的初始化。你加入一个init()函数,然后再从虚基类的其他函数中调用它,或在其他类中的构造函数里调用(你的确保它只调用了一次)。


6、不能取得构造函数的地址
  C++中,不能把构造函数当作函数指针来进行传递,指向构造函数的的指针也不可以直接传递。允许这些就可以通过调用指针来创建对象。一种达到这种目的的方法是借助于一个创建并返回新对象的静态函数。指向这样的函数的指针用于新对象需要的地方。下面是一个例子:

  class A
  {
   public:
    A( ); // cannot take the address of this
       // constructor directly
   static A* createA();
     // This function creates a new A object
     // on the heap and returns a pointer to it.
     // A pointer to this function can be passed
     // in lieu of a pointer to the constructor.
  };

  这一方法设计简单,只需要将抽象类置入头文件即可。这给new留下了一个问题,因为准确的类型必须是可见的。上面的静态函数可以用来包装隐藏子类。

7、位拷贝在动态申请内存的类中不可行
  C++中,如果没有提供一个拷贝构造函数,编译器会自动生成一个。生成的这个拷贝构造函数对对象的实例进行位拷贝。这对没有指针成员的类来说没什么,但是,对用了动态申请的类就不是这样的了。为了澄清这一点,设想一个对象以值传递的方式传入一个函数,或者从函数中返回,对象是以为拷贝的方式复制。这种位拷贝对含有指向其他对象指针的类是没有作用的(见图2)。当一个含有指针的类以值传递的方式传入函数的时候,对象被复制,包括指针的地址,还有,新的对象的作用域是这个函数。在函数结束的时候,很不幸,析构函数要破坏这个对象。因此,对象的指针被删除了。这导致原来的对象的指针指向一块空的内存区域-一个错误。在函数返回的时候,也有类似的情况发生。



图2. The automatic copy constructor that makes a bitwise copy of the class.


  这个问题可以简单的通过在类中定义一个含有内存申请的拷贝构造函数来解决,这种靠叫做深拷贝,是在堆中分配内存给各个对象的。


8、编译器可以隐式指定强制构造函数
  因为编译器可以隐式选择强制构造函数,你就失去了调用函数的选择权。如果需要控制的话,不要声明只有一个参数的构造函数,取而代之,定义helper函数来负责转换,如下面的例子:

  #include <stdio.h>
  #include <stdlib.h>
  class Money
  {
   public:
      Money();
      // Define conversion functions that can only be
      // called explicitly.
      static Money Convert( char * ch )
      { return Money( ch ); }
      static Money Convert( double d )
      { return Money( d ); }
      void Print() { printf( "\n%f", _amount ); }
   private:
      Money( char *ch ) { _amount = atof( ch ); }
      Money( double d ) { _amount = d; }
      double _amount;

   };

  void main()
  {
   // Perform a conversion from type char *
   // to type Money.
   Money Account = Money::Convert( "57.29" );
   Account.Print();
   // Perform a conversion from type double to type
   // Money.
   Account = Money::Convert( 33.29 );
   Account.Print();
  }
  在上面的代码中,强制构造函数定义为private而不可以被用来做类型转换。然而,它可以被显式的调用。因为转换函数是静态的,他们可以不用引用任何一个对象来完成调用。


总结

  要澄清一点是,这里提到的都是我们所熟知的ANSI C++能够接受的。许多编译器都对ANSI C++进行了自己的语法修订。这些可能根据编译器的不同而不同。很明显,许多编译器不能很好的处理这几点。探索这几点的缘故是引起编译构造的注意,也是在C++标准化的过程中移除一些瑕疵。


参考文献:

1. Stroustrup, Bjarne. The C++ Programming Language, 3rd ed., Addison-Wesley, Reading, MA, 1997.
2. Ellis, Margaret and Bjarne Stroustrup. The Annotated C++ Reference Manual, Addison-Wesley, Reading,   MA, 1990.
3. Stroustrup, Bjarne. The Design and Evolution of C++, Addison-Wesley, Reading, MA, 1994.
4. Murry, Robert B. C++ Strategies and Tactics, Addison-Wesley, Reading, MA, 1993.
5. Farres-Casals, J. "Proving Correctness of Constructor Implementations," Mathematical Foundations of   Computer Science 1989 Proceedings.
6. Breymann, Ulrich. Designing Components with the C++ STL, Addison-Wesley, Reading, MA,1998.
7. Lippman, Stanley and Josee LaJoie. C++ Primer, 3rd ed., Addison-Wesley, Reading, MA, 1998.
8. Skelly, C. "Getting A Handle On The New-Handler," C++ Report, 4(2):1-18, February 1992.
9. Coggins, J. M. "Handling Failed Constructors Gracefully," C++ Report, 4(1):20-22, January 1992.
10. Sabatella, M. "Laser Evaluation of C++ Static Constructors," SIGPLAN Notices, 27(6):29-36 (June     1992).
11. Eckel, B. "Virtual Constructors," C++ Report, 4(4):13-16,May 1992.
12. Coplien, James O. Advanced C++: Programming Styles and Idioms, Addison-Wesley, Reading, MA, 1992.

本文地址:http://com.8s8s.com/it/it3631.htm