以文本方式查看主题

-  计算机科学论坛  (http://bbs.xml.org.cn/index.asp)
--  『 C/C++编程思想 』  (http://bbs.xml.org.cn/list.asp?boardid=61)
----  Thinking again in C++ 系列  (http://bbs.xml.org.cn/dispbbs.asp?boardid=61&rootid=&id=39217)


--  作者:卷积内核
--  发布时间:10/23/2006 3:40:00 PM

--  Thinking again in C++ 系列
爱死Thinking in系列了,所以起了这个名字。本文的思想也部分来至于这套书,或参照对比,或深入挖掘,或补益拾慧,或有感而发,既包括Thinking in C++,甚至也包括Thinking in Java。
Thinking again in C++(一)常量性原理
关键字:C++,常量,const,constant,引用,指针,形参,实参,函数,返回值
1.不能将const修饰的任何对象、引用和指针作为赋值表达式的左值。
const int cx=100;
const int & rcx=cx;
const int * pcx=&cx;
cx=200; //error
rcx=200; //error
*pcx=200; //error
2.const类型的对象不能直接被non-const类型的别名所引用。
(1)不能将const类型的对象传递给non-const类型的引用。
const int cx=100;
int & rx=cx; //error
(2)不能将const类型的实参传递给形参为non-const类型引用的函数。
void f(int a)
{
}
void g(int & ra)
{
}
const int cx=100;
f(cx); //ok
g(cx); //error
(3)不能将const类型的对象作为non-const类型引用的函数返回值。
int & f(const int & rca)
{
return rca; //error
}
int x=100;
f(x);
3.可以使用const类型别名引用non-const对象。此时通过const引用不能修改对象,但对象可以通过non-const引用被修改。
int x=100;
int & rx=x;
const int & rcx=x; //ok

x=200;
rx=200;
rcx=200; //error
4.指针的属性有两个:指针的类型和指针本身的常量性。其中,指向const对象与指向non-const对象,是不同的指针类型。
int x=100;
const int * pcx=&x; //[1]
int * px=&x; //[2]
int y=100;
int * const cpy=&y; //[3]
int * py=&y; //[4]
[1][2]两个指针的类型不同;[3][4]两个指针的常量性不同。
对象与指向对象的指针的规则类似于对象与引用。即,const类型的对象不能直接被non-const类型的指针所指示(同2);可以使用const类型的指针指向non-const对象(同3)。
5.可以将相同类型(包括常量性)的const指针值赋给non-const指针。
int x=100;
int * px;
const int * pcx=&x;
px=pcx; //error
int * const cpx=&x;
px=cpx; //ok
6.若函数的返回值为内建类型或是指针,则该返回值自动成为const性质。但自定义类型则为non-const性质。
int f() //相当于返回const int
{
return 100;
}
int * g(int & ra) //相当于返回int * const
{
return &ra;
}
class CTest
{
int n;
public:
CTest(int n){this->n=n;}
};
CTest h() //返回的就是CTest
{
return CTest(200);
}
f()=200; //error
int x=100;
int y=200;
int * px=&x;
g(y)=px; //error
*g(y)=x; //ok,从这点可以看出g()返回的不是const int *
CTest t(100);
h()=t; //ok,但却是完全错误的、危险的做法
//所以h()的正确写法是返回const CTest(完)


--  作者:卷积内核
--  发布时间:10/23/2006 3:41:00 PM

--  
Thinking again in C++(二)自赋值是非公断

    关键字:C++,自赋值,自复制,赋值,assign,assignment,复制,拷贝,copy

    1.需要考虑的自赋值。当类包含指针或引用成员时应注意检查。
      class String
      {
      private:
            char * pc_Buffer;
      public:
            String & operator=(const String & strR);
            String & operator+=(const String & strR);
            //...
      };
    (1)类内部:对称赋值运算符、接受自身类型或自身基类类型参数的成员函数,有时候还要考虑+=系列运算符。
      String & String::operator=(const String & strR)
      {
            if (this==&strR)      //[1]
                  return *this;
            delete [] pc_Buffer;      //[2]
            pc_Buffer=new char[strlen(strR.pc_Buffer)+1];//[3]
            //...
      }
    [1]中的判断是必须的。如果this==&strR,[2]将本身删除,[3]就会使用“悬挂指针”。
    下面operator+=()的实现隐藏着错误。
      String & String::operator+=(const String & strR)
      {
            int iLengthNew=strlen(pc_Buffer)+strlen(strR.pc_Buffer);
            char * pcBufferNew=new char[iLengthNew+1];
            strcpy(pcBufferNew,pc_Buffer);
            delete [] pc_Buffer;                  //[4]
            strcat(pcBufferNew,strR.pc_Buffer);      //[5]
            pc_Buffer=pcBufferNew;
            return *this;
      }
    如果this==&strR,[4]将本身删除,[5]就会使用“悬挂指针”。正确的做法不必使用判断语句,只需调换[4][5]两条语句的顺序。
    (2)类外部(包括友元):接受多个同一类型参数或多个有继承关系的类型参数的函数。
      class CDerive : public CBase{};
      void f(CBase & b1,CBase & b2);
      void g(CBase & b,CDerive & d);
      CBase bSame;
      CDerive dSame;
      f(bSame,bSame);            //[1]
      f(dSame,dSame);            //[2]
      g(dSame,dSame);            //[3]
    [1][2][3]都出现了自赋值,所以f()、g()的设计中都要有所考虑。

    2.不可能出现自赋值。
    (1)拷贝构造器:因为正在构造的对象还未完全生成,而传递给构造器的实参对象是已构造完毕的对象,这两者绝不可能是同一对象。
    (2)非对称赋值运算符:即使形参类型是自身的基类。若D是B的派生类,无论是否重载了对称赋值运算符,D类对象之间的赋值行为都不会调用D::operator=(const B & b)。
      class CDerive : public CBase
      {
      public:
            operator=(const CBase & b);      //不用考虑this和b之间的自赋值
            void f(const CBase & b);      //需要考虑this和b之间的自赋值
      };
      CDerive dSame;
      dSame=dSame;            //[1]
      dSame.f(dSame);            //[2]
    语句[1]中,编译器不会把dSame上溯造型为CBase,而是调用缺省或自定义的D::operator=(const D & d)。只有等式左边确为D,右边确为B,才调用D::operator=(const B & b),这时不可能出现自赋值。相反,语句[2]中,编译器会把dSame上溯造型为CBase,所以f()需要考虑自赋值。

    3.不是自赋值的赋值。仅仅内容相同的赋值不是自赋值。
      CTest a,b,same;
      a=same;
      b=same;
      a=b;      //[1]
    [1]不是自赋值,不会出问题,不需要检查,而且内容相同无法直接用地址来检查。

    4.不应该检查的自赋值。
    strcpy(char * strDest,const char * strSrc);中,当strDest==strSrc时,是自赋值,但并不会出错。
    发现自赋值直接返回,这种特定情况下,也许能提高函数效率10倍,但绝大多数没有出现自赋值时都多了一个条件判断,可能降低函数效率10%,最后综合计算加权平均效率可能还是降低了。这取决于自赋值出现的概率。
    设不判断自赋值,函数执行时间为1;若检查自赋值,设出现自赋值的概率为x,直接返回函数执行时间为0.1,不出现自赋值,多了一个条件判断函数执行时间为1.1,那么如果要求加权平均效率不降低:
      0.1x+1.1(1-x)<1
    解之,得:x>0.1。也就是说自赋值出现的概率必须大于10%,这在实际代码中可能吗?



--  作者:卷积内核
--  发布时间:10/23/2006 3:41:00 PM

--  
Thinking again in C++(三)缺省参数的误区

    关键字:C++,缺省参数,default argument,函数,function,构造器,constructor,误区

    使用缺省参数时应该注意避开下列几种误区。

    1.滥用缺省参数,损害代码的结构和可读性。
      void f(bool b=false)
      {
            if (b)
            {
                  file://code of open file
            }
            else
            {
                  file://code of close file
            }
      }
    打开文件和关闭文件在实现代码上没有什么共同点,把两个属于同一类别的函数误认为是实现机制相同,凭空捏造一个参数硬把它们凑在一块,没有什么好处!相反,谁能记得住f(true)代表打开,f()代表关闭呢?况且,f(false)、f()都可以关闭文件,如果调用者混合使用它们就会增加维护上的困难。这种情况下,写成两个独立的函数,非常清晰。
      void Open()
      {
                  file://code of open file
      }
      void Close()
      {
                  file://code of close file
      }
    推而广之,如下的做法也值得商榷。
      class CString
      {
      private:
            char * pcData;
      public:
            CString(char * pc=NULL);
      };
      CString::CString(char * pc)
      {
            if (pc==NULL)
            {
                  pcData=new char[1];
                  //...
            }
            else
            {
                  pcData=new char[strlen(pc)+1];
                  //...
            }
      }
    这一个更具备迷惑性,“都是构造器嘛,当然写在一块喽。”有人说。非也!应当看到,无参构造器与带char *参数的构造器使用的代码完全分离,并且缺省参数值NULL在设置数据成员时没有任何作用。CString()构造器应改写如下:
      class CString
      {
      private:
            char * pcData;
      public:
            CString();
            CString(char * pc);
      };
      CString::CString()
      {
            pcData=new char[1];
            //...
      }
      CString::CString(char * pc)
      {
            pcData=new char[strlen(pc)+1];
            //...
      }
    总结:
    (1)凡是出现利用缺省参数值作if判断,并且判断后实现代码完全不同的,都应该分拆成两个独立的函数。
    (2)只有缺省参数值在函数体中被无歧视的对待,也就是函数对于任何参数的实现机制都相同时,才可能是合理的。

    2.多个缺省参数,可能引入逻辑含混的调用方式
    设计一个类,不仅仅是提供给客户代码正确的功能,更重要的是,对不正确的使用方式作力所能及的限制。
      class CPoint
      {
      public:
            int x;
            int y;
            CPoint(int x=0,int y=0)
            {
                  this->x=x;
                  this->y=y;
            }
      };
    乍一看,没什么问题。构造CPoint对象时如果不指定x、y的初值,则设为原点坐标。让我们测试一下:
      CPoint pnt1;
      CPoint pnt2(100,100);
      CPoint pnt3(100);      file://[1]
    结果发现pnt3的值为(100,0),跑到x轴上去了。对于想绑定两个参数,让它们同时缺省,或者同时不缺省,我们无能为力。但是如果去掉缺省参数,情况就会好转。
      class CPoint
      {
      public:
            int x;
            int y;
            CPoint()
            {
                  x=0;
                  y=0;
            }
            CPoint(int x,int y)
            {
                  this->x=x;
                  this->y=y;
            }
      };
    这样,语句[1]就会引发编译错误,提醒使用者。
    抬杠的会说:“CPoint pnt3(100);初始化到x轴,本来就是我想要的。”真的吗?那么,请你在你的类文档中明确指出这种独特的调用方法,并且告诉使用者,将点初始化到y轴是CPoint pnt4(0,100);这种不对称的形式。
    至于我嘛,self document好了。

    3.重载时可能出现二义性
    这个简单,随便举个例子:
      void f(int a,int b=0)
      {
      }
      void f(int a)
      {
      }
    虽然潜在的模棱两可的状态不是一种错误,然而一旦使出现f(100);这样的代码,潜伏期可就结束了。

    4.函数调用中的精神分裂症
    Effective C++ 2nd中的条款,为了本篇的完整性加在这里。这种罕见的症状出现的条件是:派生类改写了基类虚函数的缺省参数值。
      class CBase
      {
      public:
            virtual void f(int i=0)
            {
                  cout<<"in CBase "<<i<<endl;
            }
      };
      class CDerive : public CBase
      {
      public:
            virtual void f(int i=100)
            {
                  cout<<"in CDerive "<<i<<endl;
            }
      };
      CDerive d;
      CBase * pb=&d;
      pb->f();      file://[2]
    运行后输出:
      in CDerive 0
    记住,缺省参数是静态绑定,而虚函数是动态绑定,所以[2]运行的是CDerive::f()的函数体,而使用的缺省值是CBase的0。


--  作者:卷积内核
--  发布时间:10/23/2006 3:42:00 PM

--  
Thinking again in C++(四)运算符重载形式推导

    关键字:C++,运算符,operator,重载,overload,形式推导


                          大前提

    定理:自定义类型不要毫无价值地与内建类型不兼容(Effective C++ 2e)。这个定理是本篇所有内容的理论基础,可见它的重要性。但我无法证明,只是从直觉上认为它是正确的,如果允许的话,我更乐意把它称为公理。


                          小前提

    内建类型的运算符串链规则:
     int a=1,b=2,c=3;
     a=(b+=c);     //[A]     ok
     (b+=c)=a;     //[B]     ok
     (a+b)=c;      //[C]     error
     (a=b)=c;      //[D]     ok
     (++a)=b;      //[E]     ok
     (a++)=b;      //[F]     error


                          结论

    成员函数版运算符重载的声明形式:
     class Arrow
     {
     private:
          double x;
          double y;
     public:
          Arrow & operator+=(const Arrow & r);            //[1]
          const Arrow operator+(const Arrow & r) const;   //[2]
          double operator*(const Arrow & r) const;        //[3]
          Arrow & operator=(const Arrow & r);             //[4]
          Arrow & operator++();                           //[5]
          const Arrow operator++(int);                    //[6]
     };
    Arrow的真实含义是矢量,也就是数学中有大小和方向的量,本来英文名应该是vector,但STL中已经有了同名但不同义的东西,为以示区别就取了这个不伦不类的名字。对这个类来说,++运算符没有意义,另外,赋值运算符使用编译器的默认版本即可,写它们纯粹是起示例作用。


                          推导手段

    通过合理使用const修饰符和引用,使重载运算符的行为同内建类型一致。


                          单个解析

    [1]由于[A][B]的关系,+=运算符的返回值应能够被改写,所以传回*this的引用。
     Arrow & Arrow::operator+=(const Arrow & r)
     {
          x += r.x;
          y += r.y;
          return *this;
     }

    [2]+运算符不改变左操作数,所以函数为const性质。+的结果存放在函数的local对象中,出于安全上的考虑,local对象不能作为引用返回,又因为[C]的关系,所以传回local对象的const拷贝。
     const Arrow Arrow::operator+(const Arrow & r) const
     {
          return Arrow(x+r.x,y+r.y);
     }
    Arrow的构造器省略。

    [3]内建类型的返回值被编译器约定不能作左值,此时可省略返回值的const。其他同[2]。
     double Arrow::operator*(const Arrow & r) const
     {
          return x*r.x+y*r.y;
     }
    矢量相乘得到它们的内积,内积是数而不是矢量。

    [4]由于[D]的关系,同[1]。
     Arrow & Arrow::operator=(const Arrow & r)
     {
          x = r.x;
          y = r.y;
          return *this;
     }

    [5]自增运算符的前缀版本返回改变后的对象,又由于[E]的关系,所以传回*this的引用。
     Arrow & Arrow::operator++()
     {
          //...
          return *this;
     }

    [6]自增运算符的后缀版本返回改变前的对象,所以必须建立一个local对象保存原状态,于是函数返回值同[2]。
     const Arrow Arrow::operator++(int)
     {
          Arrow arr(*this);
          //...
          return arr;
     }


                          逆推

    从[1][4][5]的实现可以看到,我们能够自然提供[A][B][D][E],同时保证足够的严谨。而对于[2],不存在安全的手段,让形如[C]的式子通过编译。还有,为什么内建类型的串链规则[E][F]有如此微妙的差异,从[5][6]可以看出来,因为函数可以安全地返回改变后的对象,却无法安全地返回改变前的对象。
    这些都增加了大前提定理的可信度,小前提也显得更加合理。


后记:
1.限于篇幅,这里只列出了最简单的运算符重载,省略了很多关于复杂、特种运算符的讨论。
2.+、*这类二元运算符最好采用非成员形式,也就是友元形式。
如果希望看到进一步的讨论,我会再写一点。



--  作者:卷积内核
--  发布时间:10/23/2006 3:43:00 PM

--  
Thinking again in C++(五)深入认识对象初始化

    关键字:C++,初始化,initialization,对象,object


    来自实际项目的一段代码,简化形式如下:
switch (t)
{
case 0:
  int a = 0;
  break;
default:
  break;
}
    有什么问题吗?似乎没有。请用编译器编译一下……
    嗯?!一个错误“error C2361: initialization of 'a' is skipped by 'default' label”。这怎么可能?
    几番思琢,悟出解释:C++约定,在块语句中,对象的作用域从对象的声明语句开始直到块语句的结束,也就是说default标号后的语句是可以使用对象a的。如果程序执行时从switch处跳到default处,就会导致对象a没有被正确地初始化。确保对象的初始化可是C++的重要设计哲学,所以编译器会很严格地检查这种违例情况,像上述的示例代码中default语句后面并没有使用a,但考虑到以后代码的改动可能无意中使用,所以一样被封杀。
    明白了原因,解决起来就很容易了。只要明确地限制对象a的作用域就行了。
switch (t)
{
case 0:
  {  //added for fix problem
  int a = 0;
  break;
  }  //added for fix problem
default:
  break;
}
    如果确实需要在整个switch语句中使用对象a,那就把int a = 0;移到switch语句之前即可。不过从原先的语句看,其意图似乎并不是这样的,所以推荐前面的解决方案。


    结束了吗?没有。让我们继续考究错误提示信息中“initialization”(也就是初始化)的确切含义。C++很看重初始化,所以往往会给我们造成一种错觉,似乎对象在定义处一定会经过初始化过程。真实情况如何呢?还是用实例来证明吧。
switch (t)
{
case 0:
  int a;
  a = 0;
  break;
default:
  break;
}
    编译,这次没有报错。很明显int a;定义了对象,但没有进行初始化,否则就应该报告原先的错误。
    再看看用户自定义类型。
class B
{
};

switch (t)
{
case 0:
  B b;
  break;
default:
  break;
}
    编译结果也没有错误,所以没有提供构造器的类仍然没有初始化过程。
    如果给类加入构造器,情况就不同了。
class B
{
public:  //added for initialization
  B(){} //added for initialization
};
    这样就能重现原先的错误。证明有了构造器,编译器就将进行初始化处理并对之进行安全检查。


    从上面的实验,可以直观地体验到一些基本的C++观念和原理,并提高认识深度。
    1.int a = 0;既是声明也是定义,还包括初始化;int a;是声明还是定义依上下文而定,但如果是定义就不会包括初始化;a = 0;仅仅是赋值语句,在此句前对象已经存在了。
    2.为了避免不必要的开销,默认情况下,即程序员没有在代码中明确指示时,编译器不提供初始化过程。某些需要确保初始化的类,请提供构造器。这里透露出一个C++的设计哲学:通常你会面对多种选择,所以请精确地控制代码,其收益则是可以自由取舍调配的安全性、速度、内存开销等程序特性。
    3.严密注意程序中标号的使用情况,特别是case、default等常规标号,否则他们可能会破坏对象的正确状态。如果提供了对象初始化,则能够获得编译器的额外帮助。



W 3 C h i n a ( since 2003 ) 旗 下 站 点
苏ICP备05006046号《全国人大常委会关于维护互联网安全的决定》《计算机信息网络国际联网安全保护管理办法》
125.000ms