`
阿尔萨斯
  • 浏览: 4170779 次
社区版块
存档分类
最新评论

五 类的设计

 
阅读更多
类的设计
struct和class
struct和class最重要的区别是哲学上的,struct代表着C风格设计思想,class代表着面向对象c++的设计思想,延伸一下,typename代表着c++泛型的思想。
class和struct在大多数情况下可以互换,是因为c++要兼容c的代码所致,就像typename和class可以互换一样。这里我们有足够的自由去选择用struct和class,看你打算在自己的设计中使用哪一种编程哲学。
除此之外,struct默认访问权限是public,而class默认访问权限是private。还有一点,struct里面的成员变量的内存布局总是按照声明的次序排列的,但是class不一样。在同一个访问权限块内部的成员变量内存布局总是按照声明的次序排列,但是如果多个访问权限块内部的成员变量,顺序不能假定一定是连续的。
同样的情况,如果一个类派生自另一个类,我们不能假定这个子类的成员变量总是排在父类的成员变量之后,虽然多数实现的确如此。

类的基本元素
成员变量分为静态和非静态;
成员函数分为静态函数和非静态非虚函数以及虚函数;
同时还有从基类继承下来的成员变量和成员函数;
采用虚继承导致的额外的成员变量和数据结构(由各编译器自己实现,标准没有规定)
例如:

类ostream,istream分别代表输出和输入流,它们均派生自ios类,由于考虑到iostream从它们两个派生以同时具备输入和输出流的能力,因此ostream和istream均虚继承自ios类,使得iostream对象中不会拥有两份ios对象的数据成员拷贝。
class ostream:virtual public ios
class istream:virtual public ios
class iostream:public ostream,public istream
虚继承的语法就是提醒编译器,不要将父类的数据成员放到子类的内存空间中。C++标准并没有规定具体要怎么做,编译器可以实现自己的策略。一种可能的布局是:
ostream和istream以及iostream中都有一个指针,指针指向一个表格,表格中存放的是虚基类的起始地址。
虚函数
虚函数的内存布局
一个拥有虚函数的类内部通常会有一个成员变量vptr,一个四字节大小的指针,指向虚函数表,虚函数表中记录了该类的各个虚函数的入口地址,如果该类重写了继承的虚函数,那么就存放自己的虚函数地址,否则就是父类的虚函数地址。以下是一个Point类的声明:
class Point
{
public:
Point(float);
virtual Print(ostream& stream);
virtual ~Point();
static int PointCount();
static int _point_count;
float x();
private:
float _x;
};
他的可能的内存布局如下:

在这里我们可以看到,静态成员变量、成员函数都不会占用对象的内存空间。占用空间的通常是非静态成员变量和从父类继承下来的非静态成员变量以及虚函数指针,当然虚继承导致的额外的成员指针也要占用空间。
class A
{
public:
virtual void f(){};
virtual ~A(){};
};

class B:public A
{
void f(){int i=0;};
};

A* pA=new B();
pA->f();
对于f的调用操作编译器有如下动作:
void B::f()函数解释为void f(B* this);
pA->f()解释为 (*pA->vptr[1])(this);//1是f函数在虚拟函数表格中的索引

所以我们可以看出,虽然指针pA静态类型为A类的指针,但是对于f的调用是依赖于B对象内部的vptr指向的虚拟函数表,而此时函数表内的f函数已经是B类的重载版本,因此这就构成了运行时多态,这个c++的基本特征。

虚继承情况下的一种可能的内存布局,摘自《c++对话系列》。
class parent { /* whatever */ };
class child1 : public virtual parent { /* whatever */ };
class child2 : public virtual parent { /* whatever */ };
class multi : public child1, public child2 { /* whatever */ };

parent::vptr
parent data
child1::vptr
child1 data
child2::vptr
child2 data
multi::vptr
multi data
在这种复杂的情况下,最底层派生对象内部拥有三个vptr,指向三个虚函数表。
注意,经过我的实验,这种内存布局各种编译器表现不一样,比如vc就是先把child1放在最前面,然后是child2,最后是parent,并且至少vc中,我们可以通过调用static_cast获得各个类型的vptr值,这种典型的应用是在com的QueryInterface函数的实现里面。
经过上面的分析,我知道上面的虚继承会带来多个虚函数表以及多个vptr,这是内存上的额外的开销,当然避免了多个顶级父类的内存副本,也有虚继承的好处。
我们可以看看com里面常常出现的多继承带来的对象内存布局:
class CPenguin : public IBird, public ISnappyDresser {...};
IBird和ISnappyDresser接口都继承自IUnknown接口,内存布局如下图:


纯虚函数和虚函数的区别
参考 <<Effective C++>> 3th Edition,这里作个简单的概括

纯虚函数分为函数定义和没有函数定义----
没有函数定义的纯虚函数目的是为了让子类继承接口,强制子类实现该函数;
有函数定义的纯虚函数目的是为了让子类继承接口,而父类的实现函数必须在子类的该函数中手动调用

非纯虚函数目的是让子类自动的继承接口和函数实现
虚函数与访问权限
虚函数的重写机制和访问权限是相互独立的两套机制,互相没有干扰,但是可以结合使用。
一个private权限的虚函数可以被子类重新实现,但是子类不能访问该虚函数,而父类却可以通过运行时多态的方式来调用子类重载后的虚函数。
一个protected权限的虚函数可以被子类重新实现,子类也可以访问该虚函数,外部代码不可以访问。
一个public权限的虚函数可以被重新实现,子类也可以访问,外部代码也可以访问。
虚函数与设计模式
虚函数和template method模式的结合也被称为NVI(non virtual interface)手法。类只暴露非虚函数f,同时提供一个保护或者私有的do_f虚函数,在f函数中调用do_f。派生类重载do_f从而提供自己的独特功能。
Template Method模式的好处就是提供统一的调用框架,在基类的f函数里面调用,而交给派生类自己订制的行为。比如f函数里面,可以在调用do_f之前与之后添加一些代码,执行内存分配和清除动作。
ACE采用了这种机制将通信程序的常规步骤实现在基类的成员函数中,而每个步骤都由一个虚函数完成,用户可以实现自己的子类虚函数已达到特定的业务需求。
这种设计观点认为虚拟函数应该和数据成员一样对待----让他们成为私有的,除非设计需求表明应该有较少的限制。提升它们到更高存取级别比把它们降到更私有的级别更容易些。
类对象的构造
当构造函数执行的时候,类对象所需要的内存已经分配好了,正如我们前面描述中反复提到的,可能在5种不同的区域中分配到了空间。不过具体是什么区域在构造的时候就不用关心了。
对象构造和析构是程序员和编译器互相帮助完成的过程,为什么这么说呢?首先我们要弄清楚对象构造通常需要完成哪些事情?
如果我们的类里面有几个成员变量,这些成员变量按照什么顺序排列,如何初始化成员变量?
如果我们的类有虚函数,那么虚函数指针放在什么位置,如何初始化该指针,以及虚函数表的初始化?
如果我们的对象有父类,父类的成员变量和子类的成员变量是放在一起么?如果放在一起的话他们的顺序又如何?
如果我们的父类有好几个,又怎么办?
如果我们的父类是虚继承的,我们如何做到在继承体系中避免出现多份父对象的拷贝?
这些问题大多数可以加上一句:当对象析构的时候,情况又如何?
回答这些问题有助于帮助我们认识到哪些事情编译器会自动帮我们做,我们就不用再作了,哪些事情编译器虽然帮我们做了,但是可能达不到我们的要求,我们要修改或者禁止编译器的默认行为。
比如:
class A
{
public:
A():_str()
{

}
private:
string _str;
}
上面的行为有点点傻,因为编译器会帮助我们创建一个默认构造函数,内部调用_str的默认构造函数,我们何必自己来呢?

如果我们写了一个class,比如:
class A
{
};
这是一个空类,当需要的时候,编译器会帮助我们创建默认构造函数、析构函数、拷贝构造函数、拷贝赋值函数。所谓默认构造函数就是没有参数的构造函数.因为有了默认构造函数,我们才可以象下面一样创建对象。
A a;
A a[10];
如果我们提供了自己的构造函数,编译器就不会再产生了默认构造函数了。比如:
class A
{
public:
A(int i){};
};

A a;//出错,没有默认构造函数
所以这种情况下,我们要提供自己的默认构造函数,哪怕什么也不做。
class A
{
public:
A(){};
A(int i){};
};

A a;//ok,正确

拷贝构造函数,就是接受和自己一样类型的对象引用作为参数的构造函数
class A
{
public:
A(int i){};
A(A const& a)
{
...
};
};
关于默认构造函数,我们需要清楚的知道编译器在什么时候会自动为我们编写一个,主要有四种情况:
1)如果我们设计的类里面有成员变量,该成员变量拥有默认构造函数,比如:
class BaseClass
{
public:
virtual ~BaseClass();
private:
string _str;
};
因为编译器需要有个地方调用_str的默认构造函数,所以它会自动产生如下伪代码:
BaseClass::BaseClass()
{
_str.string::string();
}
2)如果我们的类的父类拥有默认构造函数
class DerivedClass:public BaseClass
{
};
因为创建一个子对象之前,一定要创建父对象,所以编译器也会产生一个默认构造函数,伪代码可能如下:
DerivedClass::DerivedClass()
{
this->BaseClass::BaseClass();
}
3)如果我们的类拥有虚函数
为了实现虚函数的多态特征,编译器需要在类的内部添加一个虚函数指针,并指向一个虚函数表,同时虚函数表中需要存放好合适的函数地址以及类信息。因此,编译器也需要一个地方做完这些初始化动作,所以也会替我们生成一个默认构造函数。
4)如果一个类虚继承自另一个类
编译器也需要在子类中增加合适的成员,通常是指向父类的指针。
上面的四点情况,有两个共同点:
1)只要我们添加了自己的非默认构造函数,编译器就不会创建默认构造函数,并且会把原来打算在默认构造函数里面做的那些事情,转移到我们自己的非默认构造函数内(通过安插代码)
2)编译器这么做的理由就是,它必须要做一些你看不到的初始化,才能保证对象正确工作。
还要注意的是,编译器为了自己的需要创建构造函数,行为可能不如你预期,比如:
如果你的成员变量是指针变量或者int型变量,不要指望编译器会自动帮你初始化为0。只有全局或者静态变量才有此行为。
类对象的销毁
我们通常在析构函数中清除必要的数据。如果我们不提供,编译器会在下列情况下为我们生成:
1)我们的成员变量有析构函数
class MyClass
{
private:
string _str;
}
编译器会产生如下伪代码:
MyClass::~MyClass()
{
_str.string::~string();
}
注意,这是个非虚析构函数
2)我们的父类有虚析构函数
class BaseClass
{
public:
virtual ~BaseClass();
private:
string _str;
};

class DerivedClass:public BaseClass
{
private:
string _str2;
};
编译器会产生如下伪代码:
virtual DerivedClass::~DerivedClass
{
_str2.string::~string();
BaseClass::~BaseClass();
}
注意,这是个虚析构函数
这里,我们也可以看出,当有继承的情况时,构造总是先构造父对象(当有多个父类时,父对象的创建按照声明顺序),然后构造子对象;析构总是先析构子对象,然后析构父对象。因此,析构的顺序总是和构造的顺序相反。
类的成员变量如果有多个,它们的构造顺序和声明顺序相同,析构顺序和声明顺序相反。
关于析构函数有一个常识,就是如果你不打算自己的类被别的类继承,请让它成为非虚函数。如果你打算让别的类继承它,就让它成为虚函数。比如:
class BaseClass
{
public:
virtual ~BaseClass();
private:
string _str;
};

class DerivedClass:public BaseClass
{
private:
string _str2;
};
这是因为,客户代码可能试图这样销毁对象:
BaseClass* p=new DerivedClass();
….
delete p;//调用子类重载的虚析构函数
本质上,这和调用p->f() (假设f是虚函数)没什么区别,由于p的动态类型是DerivedClass*,所以,会调用DerivedClass的析构函数,因此子类的成员_str2会得到机会被清理。
如果DerivedClass的析构函数为非虚函数,那delete p的结果将是根据p的静态类型(BaseClass*),只调用BaseClass::~BaseClass()函数,_str2没有机会被清理。

类对象的拷贝
浅拷贝和深拷贝
默认情况下,如果你不自己创建一个拷贝构造函数和赋值函数(operator =)的话,编译器会在需要的时候为你创建一个。例如:
class A
{
private:
char* _p;
string _str;
};

A a1,a2;
a1=a2;//1)
A a3=a1;//2)
A a4(a1);//3)

在1)这种情况下,编译器可能会创建拷贝赋值函数:
A& A::operator =(A const& right)
{
_p=right._p
_str=right.str;
return *this;
}
在2)和3)的情况下,编译器会生成拷贝构造函数:
A::A(A const& right)
{
_p=right._p
_str=right.str;
}
也许有人会奇怪,为什么2)会导致生成拷贝构造函数而不是赋值函数,可以这样理解,赋值函数必须作用于已经构造好的对象,在2)这种情况下,a3对象尚未构造,所以需要拷贝构造函数。
2)如果用伪码表示,可能如下:
A a3; //定义一个变量,获得栈分配的内存
a3.A::A(a1);//调用拷贝构造函数
如果注意观察,上面的代码应该提示你,编译器对指针变量作了简单的拷贝,也就是说现在两个A对象的_p变量都指向自由存储区域同一块内存(假如有的话)。这就是所谓的浅拷贝。
如果A的析构函数这么写:
A::~A()
{
delete _p;
}
你就可能陷入麻烦,完整代码如下:
class A
{
public:
A()
{
_p=new char[100]();
strcpy(_p,"hello,world");
}
~A()
{
delete _p;
}
private:
char* _p;
};

int _tmain(int argc, _TCHAR* argv[])
{
A a1;
A a2;
a2=a1;

return 0;
}
A a1;A a2两句话执行之后,a1和a2的构造函数都在自由存储区域获得了一块100字节的内存,并且各自的_p变量都指向它们。当a2=a1执行时,a2._p原来指向的100字节内存将没有任何指针指向它,结果是内存泄露;由于a2._p和a1._p都指向同一块内存,当_tmain返回时,根据栈的原理,a2的析构函数会被调用,delete _p将会回收自由存储上的那块100字节的内存,然后a1的析构函数也会被调用,试图再次回收那块内存,引发错误。
这都是编译器默认提供的浅拷贝惹的祸!那我们就用深拷贝解决。我们决定自己实现这个赋值函数。
class A
{
public:
A()
{
_p=new char[100]();
strcpy(_p,"hello,world");
}

A& operator =(A const& right)
{
if(this!=&right)
{
delete[] _p;//回收原来分配的内存
_p=new char[100];//重新分配一块内存
memcpy(_p,right._p,100);//将值作一份完整的拷贝
}
return *this;
}

~A()
{
delete _p;
}
private:
char* _p;
};

int _tmain(int argc, _TCHAR* argv[])
{
A a1;
A a2;
a2=a1;

return 0;
}
拷贝构造函数的原理相同,这里就不写了。可能有人要说,编译器是个大笨蛋,不能指望它。是的,编译器考虑得很简单,如果成员变量有拷贝构造函数或者赋值函数,就调用之,如果没有,在语法允许的条件下就简单的内存拷贝一下了事。但是先别怪编译器,因为这样做是有苦衷的。
假如我们刚才分配了一个巨大的内存块,如果编译器默认给我们提供了深拷贝,它又会挨骂了!有时候,我们就是需要浅拷贝,也许我们是在一个内存很小的环境里面,能省点就省点巴。
如果我们的确需要浅拷贝,但是又想安全点,怎么办呢?引用计数是个好办法。
class A
{
public:
A(char* p):_p(p)
{
++ref;
}

A(A const& right)
{
if(this!=&right)
{
++ref;
_p=right._p;
}
}

~A()
{
if(--ref==0)
delete _p;
}
private:
char* _p;
A& operator =(A const& right);
public:
static int ref;
};

int A::ref=0;

int _tmain(int argc, _TCHAR* argv[])
{
char* p=new char[100]();
strcpy(p,"hello,world");
A a1(p);//ref=1
A a2(a1);//ref=2
return 0;
}
使用一个静态成员变量int A::ref来统计有多少A对象的指针指向了自由存储区域的100字节内存块,只有当没有指针指向的时候,才通过delete销毁内存块。COM就是这样的思想。
有经验的程序员马上会想到这样做有一个问题,如果有一个A对象的某个操作修改了_p指向的值,就会影响到所有的A对象,有时候这并不是我们想要的结果。好,现在Copy on Write技术登场了。
Copy on Write的意思是只有当你真正想要修改数据的时候,你才深拷贝出一个副本,然后在你自己的副本上进行修改。除此之外,包括拷贝构造和赋值函数,都只是维护一个引用计数器,这样能够同时照顾到节省内存,提高效率和保证对象的独立性。天下没有免费的午餐,编码复杂度就大大提高了,如果考虑到多线程的话,引用计数是否需要线程安全也是一件需要仔细权衡的事情。
某些编译器的string类就是这样实现了,比如GCC 2.6版本。
多态下的克隆
C++程序中,经常会让客户通过基类指针调用某个对象,这样客户代码可以基于某种抽象概念而编写逻辑,并不需要关心实际动态对象是什么类型,系统地耦合度被很好的降低了,好事情!
但是有时候你不只需要调用函数,你可能需要创建一份副本,但是你只有基类的指针,并不知道指针背后的实际类型是什么,怎么办?比如:
class Base
{
public:
virtual ~Base(){}
virtual void F()=0;
};

class Derived:public Base
{
public:
virtual void F()
{
cout<<"Derived::F()"<<endl;
}
};

Base* Copy(Base* pBase)
{
//How can I do?
}

int _tmain(int argc, _TCHAR* argv[])
{
Base* p=new Derived();
p->F();
return 0;
}
Copy函数实现者所有能知道的信息就是pBase参数,指向Base类型,他不知道该如何真正拷贝出一份对象,并返回出去。这时候我们需要Base和Derived类的设计者提供帮助,他应该为Base加上一个虚函数,virtual Base* clone()=0;并且在Derived类中实现该函数。结果可能是这样:
class Base
{
public:
virtual ~Base(){}
virtual void F()=0;
virtual Base* clone()=0;
};

class Derived:public Base
{
public:
virtual void F()
{
cout<<"Derived::F()"<<endl;
}
Base* clone()
{
return new Derived();
}
};

Base* Copy(Base* pBase)
{
return pBase->clone();
}

int _tmain(int argc, _TCHAR* argv[])
{
Base* p=new Derived();
p->F();
return 0;
}
这个思想被抽象并概括为克隆(原型)模式,属于创建型设计模式的一种。
关于对象的拷贝还有一点就是,你可以想办法禁用拷贝构造函数或者赋值函数,因为上面的那种情况,一个缺乏多态常识的Copy函数的实现者可能会这样自作聪明:
Base* Copy(Base* pBase)
{
return new Base(*pBase);
}
将这两个函数声明为私有,并且不提供实现即可。
class Base
{
protected:
Base(){};
public:
virtual ~Base(){}
virtual void F()=0;
virtual Base* clone()=0;
private:
Base(Base const& right);
Base& operator= (Base const& right);
};

class Derived:public Base
{
public:
Derived(){}
virtual void F()
{
cout<<"Derived::F()"<<endl;
}
Base* clone()
{
return new Derived();
}
};

Base* Copy(Base* pBase)
{
return new Base(*pBase);//编译器会阻止这种行为
}

int _tmain(int argc, _TCHAR* argv[])
{
Base* p=new Derived();
p->F();
return 0;
}
访问权限
public成员意思是外部程序可以直接访问该类对象的成员或者成员;
private成员意味着外部程序和子类都不可以访问的成员
protected成员意思是外部程序不可以访问的成员,但是子类可以访问该成员
在前面的拷贝构造函数和赋值函数中,我们就使用了private权限阻止拷贝行为的发生。我们还可以将构造函数或者析构函数加上protected或者private权限,这样可以阻止普通的创建或者销毁,为什么这样做呢?因为我们可能将创建对象的责任交给了某个工厂类,创建型设计模式就是这样做的。比如:
Factory* pFactory=new Factory();
Product* pProduct=pFactory->create();
如果没有继承,就不会有protected访问权限,也就是说,protected访问权限给了子类一个机会可以访问父类的protected成员。所以下面的代码错误和正确都很合理:

class Base
{
public:
virtual ~Base(){}
protected:
int _a;
};

class Derived:private Base
{
public:
Derived(){}
~Derived(){};
void Print()
{
cout<<_a<<endl;
}
static void Print(Base& b,Derived& d);
};

void Derived::Print(Base& b,Derived& d)
{
cout<<b._a<<endl;//错误,不能通过基类访问保护成员
cout<<d._a<<endl;//正确,可以通过子类访问保护成员
}

int _tmain(int argc, _TCHAR* argv[])
{
Derived d;
d.Print();
return 0;
}
类的面向对象封装
封装是面向对象的核心概念,面向对象的定义是:数据以及操作这些数据的函数的组合。所以C风格的struct从设计哲学上来讲不是面向对象的封装,因为它并不打算隐藏数据成员。但是封装并不只是意味着一个class和一组成员函数以及背后隐藏的私有数据,通过虚函数实现的动态多态以及通过模板实现的静态多态也是一种封装。
封装的一个例外就是当我们的class只是一堆对象的组合,比如表达一行记录的很多字段,这种功能不算完整的类并不提供自己的成语函数去封装这些数据,这时候不采用封装就是沿用C风格的设计思想。
Herb Sutter提出了封装数据成员的一个准则:总是将所有的数据成员放在私有区段。例外之一是C风格的struct,后者的意图并不在于封装什么东西,因而其所有成员都可以是公有的。
这里需要解释一下为什么没有提protected封装。实际上protected没有做到真正的封装,父类的设计者决定将自己的某个成员变量定为protected的时候,也就意味着允许派生类直接访问该变量,也就意味着对派生类的作者充分信任,相信他了解自己的真实意图并且技术和道德水平高超不会胡来。事实上我们设计成员函数用以封装成员变量就是因为我们不相信别人有时间有精力去处理好我们的成员变量,我们需要用封装降低耦合度,减少软件程序中的意外事件。当我们用了protected成员变量的时候,就像我们用了public成员变量一样,这些目的一个也没有达到。
所以,父类的设计者仍然应该将成员变量设计为私有的,并且如果想要让子类拥有访问权限,提供一个内联的保护成员函数巴,这才是封装。
成员函数
内联函数
内联的作用
函数调用是有开销的,调用前要保存寄存器的状态,函数返回后要恢复寄存器的状态,栈的创建和销毁,参数的传递导致的开销等等。
如果一个内容简短的函数,相对于较短的执行时间,函数调用的开销就是一个无法忽略不计的大开销。内联函数提供了一种方法,可以建议编译器在调用带有标记inline的函数的地方展开函数的内部代码,避免这种开销。
注意,这是建议,编译器可以拒绝,如果函数实现过于复杂或者有递归,编译器拒绝的可能性较大。并且,一个函数标记上inline并不表示它不再是一个函数,它仍然是一个函数,只是有可能它在被调用的地方被展开而已。
内联的语法要求
内联函数需要放在头文件中定义。由于头文件通常会被包含多次,所以内联函数经常会被定义多次,不过由于定义完全相同,所以不会有问题。
内联导致代码膨胀?
通常我们认为如果一个inline函数被调用太多次数的话,会产生大量的扩展代码,使程序的大小暴涨。但是并不总是这样,因为inline函数的体积如果小于编译器为调用函数所产生的代码的体积,那么inline反而会导致代码总体体积变小,当然这种情况属于少数。
内联可以弥补宏的不足
有不少c++代码通过定义宏达到避免函数调用开销的同样的效果,请看:
#define CALL_WITH_MAX(a,b) f((a)>(b)?(a):(b))
但是这样的宏是有危险的,当你传递表达式的时候问题就来了:
int a=5,b=0;
CALL_WITH_MAX(++a,b);//a 最终变成7
CALL_WITH_MAX(++a,b+10);//a最终变成6
如果使用内联函数,我们就可以防止这种传递表达式的错误用法,并且获得较好的性能:
template<typename T>
inline void callWithMax(T const&a,T const& b)
{
f(a>b?a:b);
}
内联带来较高的代码耦合性
除了带来体积的问题(也许你并不在意),inline函数必须写在头文件中的规则意味着客户代码和你提供的头文件的紧耦合,一旦内联函数修改,客户的代码就需要重新编译,尽管客户的代码根本不需要调用该内联函数。这会降低整个应用程序编译的效率。
内联提高性能了么?
“内联通过避免函数调用的开销,可以提高性能”这句话并不总是有效。如果一个内联函数被频繁调用,这会对性能提高有帮助,但是如果调用并不频繁,这样做的结果是增大了调用内联函数的函数的代码尺寸,或许使得整个应用程序性能变差或许性能提高并不明显。
内联不利于debug
被编译器接受的内联函数是很难调试的。
什么时候使用内联?
结论是:由于使用内联的优缺点很多,同时又受到不同编译器的差异的干扰,我们很难用大脑准确判定是否真的从内联中获得了好处,因此不要过早的使用内联。一开始应该忽略内联的使用,直到发现了性能的瓶颈,直到你用分析器发现有些函数被频繁调用可以通过内联来提高性能。而分析器通常很难告诉你哪些内联的函数其实不应该内联,这也是不要过早内联的原因。

内联发生在什么时候?
1)程序员在编码期手动加上inline标记建议编译器内联
2)编译器在编译期的时候可能会拒绝你的inline邀请,也有可能对你并没有标记inline的函数进行内联,也有可能对一个函数的某些调用点进行内联,另一些不进行内联,总之,是你无法控制的,每个编译器行为都不一样。
3)一些优秀的连接器可以对没有函数定义的只有.o文件的函数在链接期内联,甚至函数不是由c++编写的。比如vc++7.0以上版本支持/LTCG开关,HP的编译器支持跨模块的内联。
4)当遇到C#或者Java这种使用虚拟机的高级语言,应用程序安装时往往会将一部分中间代码转换成于平台相关的本地机器指令,如果一个C#代码代用了一个c++函数,此时就有可能对这个c++函数进行内联
5)运行时也有可能进行内联。一些工具可以利用探测器测试哪些函数被频繁调用,并且记录下这些信息,这些信息可以指导修改可执行文件的内存镜像,还有一种方式试图通过猜测对象的实际类型将虚函数内联。
6)其他时候比如即时编译期间也可以尝试内联。
优先编写非成员非友元函数
为一个类设计成员函数需要注意一条重要规则:优先编写非成员非友元函数。
有一些函数总应该是成员函数:
构造函数、operator =、operator ->、operator []、operator()
在下列几种情况下,应该编写非成员函数:
1)函数需要与其左参数不同的类型
2)需要对其最左参数进行强制转换
3)能够适用已经存在的类的公有成员函数实现该函数

优先编写非成员函数非友元函数的理由是:
1)避免一个类因为提供太多的成员函数,提供了太多的功能而被设计成单片巨类。遵守类的单一职责设计原则。
2)提高类的封装性
成员变量通常是私有的,对类的用户来讲这是不可见的,它被封装了。因此面向对象的封装意味着较少的东西可以被类的客户看见,因此类的设计者可以有更多的可能修改设计,只要不影响客户可以看到的那一部分接口就行了,所以封装也意味着更好的弹性。
因此如果类的客户看到的类的接口(通常是公有成员函数)的数目越少,类的设计者在修改类的设计时就越方便,因为他所需要修改的接口数目就越少。
因此,如果我们尽量使用非成员非友元函数来为类提供一些功能,它们的实现只能依赖于已经存在的类的公有成员函数,这样的功能增强并没有增加用户看到的类的接口数目,在效率相同的前提下,显然这是非常好的决策。

作为更详细的论述,可以参考<<Effective C++>> 3th Item24和<<Exceptional C++ Style>> Item37-40
函数重载
如果有多个成员函数,它们的函数名称相同,参数数目或者类型不同,const类型不同(返回值不考虑)
const居然可以作为重载的区别?这是因为C++类的成员函数都可以被解释成带this参数的函数,比如:
class A
{
public:
void F();
void F() const;
private:
int _x;
static int
_y;
};

第一个函数可能被编译器改编成void A_F(A * const this);
第二个函数可能被编译器改编成void A_F(A const* const this);
现在可以看出,实际上const修饰符影响到了this指针的类型,所以能够用作重载区别符号。
同时我们也可以看出,成员函数因为有了this指针,所以才可以访问this->_x这样的对象成员,而静态成员函数因为没有this指针,所以不能访问对象的成员,只能访问类的静态成员函数或者变量,比如A::_y。
因此,下面的调用代码很可能被编译器改编成这样:
A a;
a.f();

A a;
a.A::A();
A_f(&a);
重载决议
如果一个类提供了多个成员函数,那么编译器按照什么顺序选择一个合适的成员函数呢?
编译器会寻找参数类型最匹配的重载函数,然后选出一个(当然如果有超过一个,编译器就会报模棱两可的错误)。然后再考虑访问权限是否允许,如果不允许,编译器会报出不允许调用的错误。
所以,你不要试图用访问权限来干扰重载决议,它不会像你想要的那样工作:
class A
{
public:
void f(int* p){}
private:
void f(char* p){}
};


int _tmain(int argc, _TCHAR* argv[])
{

A a;
a.f(NULL);
return 0;
}
你希望调用的是void f(int*),但是你肯定会失望。

重写函数
如果不是虚函数,请不要重写。原因有二:
1)因为它不具备虚函数的动态绑定特征,所以当你使用指向基类的指针去调用某个函数,该函数已经被你重写,并且指针实际上指向的的确是你的子类,编译器却绝对不会调用子类的版本,因为这个时候指针的静态类型说了算,调用基类的版本。
2)非虚函数本身就意味着派生类需要继承接口和实现,不应该修改,因为这代表着不变性凌驾于特异性。

类的继承
作为继承的第一条原则,不要滥用继承,你必须有足够的理由才能使用它。
面向对象软件工程将庞大的系统拆分成一组对象,其中一个重要的目的是解耦。而继承关系是C++中和友元几乎并列的将两个类紧密联系的方式,所以不能滥用。
当我们要表达两个类的关系的时候,我们有几种选择:
1)is a关系
公有继承用来表达子类对象是一个(is a)父类对象,比如 如果有一个类Employee表达公司员工。那么软件工程师SE是一个公司员工,所以我们可以用
class SE:public Employee来表达。
作为另一个必要条件,凡是父类可以使用的地方,公有子类都可以使用。请注意这句话是一个更加严格的限制,比如:
企鹅是一种鸟,正确;鸟会飞,所以企鹅也会飞,错误。
因此上面的公有继承关系不正确。正确的做法应该为:

绝大多数情况下,公有继承应该符合下列两个条件:
a)Derived class IS-A base class (IS-A 原则)
b)Derived class WORKS-LIKE-A base class(WORKS-LIKE-A原则)
我们来举一个符合第一个原则,但不符合第二个原则的例子----基类Rectangle和公有派生类Square。Rectangle提供了虚函数SetWidth用来允许客户设置宽度,但是派生类Square重写了该方法,使得宽度修改的同时高度也已经被修改。Square类的行为不再表现的像它的父类一样,所以不能使用公有继承表达他们之间的关系。尽管数学上正方形是矩形的一种,可这是c++,不是数学领域。:)
这两个原则反映了面向对象的五大原则之一:LSP(Liskov Substitution Principle).
有这么一种极端情况,如果设计者能够保证用户没有机会以多态的方式来使用子类,出于方便的目的使用公有继承也是可以的。比如<<Exceptional C++>>关于ci_char_traits公有继承char_traits<char>类,并改写其中的几个静态成员函数的例子。因为STL内部不会以多态方式使用该类。
这里使用的是新的替换原则:GLSP,G代表泛型。作为模板参数传递的任何类型都需要遵守这个参数的需求,提供一些成员函数。
Scott Meyers强调,设计类的时候要考虑软件要解决的问题,而不是盲目的贴近现实,如果我们的软件并不需要考虑Bird的会飞这种特征,那么上面的那幅对象关系图就是正确的。
我的观点就是,在我们的需求环境下,使用IS A和Work Like A这两个条件来衡量我们的公有继承是否恰当。
2)has a关系
这种关系很简单,就是将一个对象作为另一个类的成员变量,这是一种值得推荐的做法,这样编码工作可能较大,但是带来了更多的灵活性,比如可以包含多个实例,可以将数据成员隐藏到编译器防火墙后面(利用Pimp技术)。
3)is implemented in terms of关系(据此实现)
如果我要设计一个新类,我发现已经有一个类提供了一些功能能够帮助我更快的实现我的新类(也就是据此实现),我们可以使用私有继承或者保护继承。Adapter设计模式中介绍了这种用法,如下图:

当然,我们也可以使用has a来达到据此实现的目的,has a 仍然是比私有或者保护继承更优先推荐的做法,如下图:

私有继承的目的和保护继承差不多,只是所有的基类成员都会被变成private成员,因此不可被再继承。
私有继承和保护继承都表达同一种意思,我利用现有的某个类的实现来完成我的类的实现,只是为了代码重用,别无它的目的。(IS-IMPLEMENTED-IN-TERMS-OF 根据某物实现出)。私有、保护继承只是继承某个类的现有的实现,不需要遵守公有继承的两个原则,或者说,Derived class is not a base class,and will not work like a base class。
保护继承通常用于再继承的情况,如果我设计的类需要继承某个类的公有和保护成员,并且我还希望这些成员能够被我设计的类的派生类访问,这时候我可以使用保护继承。

4)多继承
如果你打算让你的类从多个父类继承,你最可能遇到的就是模棱两可的编译错误。
如果两个父类都实现了函数f,那么当调用子类对象的f函数时,编译器怎么知道你要用哪个呢?显式的指出你打算使用哪个父类的成员是一个解决办法,加一些垫片类也是一个办法。
确保你不会遇到这样的问题,是首先要考虑的。如果父类的其中一个是由你设计,将它设计为什么都不做也没有成员变量的纯虚类是个不错的办法。事实上,C#就是通过这种方式来支持多继承的。
C++中的多继承还会遇到菱形继承的情况。

如果ostream和Istream都是公有继承自ios的话,那么它们每个对象内部都有一份ios的副本,那么当iostream从这两个类多继承的时候,就会有两份Ios的副本出现在iostream对象的内存布局中。
5)虚继承
虚继承是为了解决一个类被多次继承而造成的副本过多的问题,如上图。需要注意的是,构造子对象时,编译器总是先构造虚基类,正如前面介绍过,虚继承通常导致ostream,istream和iostream子类中都会有一个指向ios的指针,因此对于性能有一定的影响。
如果是公有继承,基类的public和protected成员在子类中仍然是public和protected的;如果是受保护继承,则都变成了protected成员;如果是私有继承,则都变成了private成员。

6)继承并不总意味着多态
前面提到作为父类析构函数通常要设计为虚函数,这是因为防止用户这样使用delete pBase导致的错误。凡事总有例外。如果一个类设计的时候就确定是为了表达一种is a的抽象关系,并且不打算支持多态,只是为了让子类重用父类的一些成员,可以这样做:
首先,将构造函数限定为保护函数,这可以确保用户必须创建子类对象,这样可以防止一定的意外。然后应该在文档上说明设计非虚析构函数的目的。ACE中的wrapper facade出于底层库的性能的原因回避了多态,大多采用了这种设计。比如ACE_SOCK类。


Pimpl技术
分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics