简介
GitHub:<>
chap01:object lesson
c语言的数据抽象和这些数据对应的操作是分离的。例如
//数据定义(shared, external)
typedef struct point3d
{
float x;
float y;
float z;
} Point3d;
//在数据抽象Point3d上的操作
//1.定义成函数
void Point3d_print( const Point3d *pd )
{
printf("( %g, %g, %g )", pd->x, pd->y, pd->z);
}
//2.定义成宏
#define Point3d_print( pd ) \
printf("( %g, %g, %g )", pd->x, pd->y, pd->z );
//数据成员的获取也是很直接
Point3d pt;
pt.x = 0.0;
inline:
在c++中,Point3d会被实现成抽象数据类型(ADT)
- 直接实现
class Point3d
{
public:
Point3d( float x = 0.0,
float y = 0.0, float z = 0.0 )
: _x( x ), _y( y ), _z( z ) {}
float x() { return _x; }
float y() { return _y; }
float z() { return _z; }
void x( float xval ) { _x = xval; }
// ... etc ...
private:
float _x;
float _y;
float _z;
};
inline ostream&
operator<<( ostream &os, const Point3d &pt )
{
os << "( " << pt.x() << ", "
<< pt.y() << ", " << pt.z() << " )";
};
- 多层继承
//基类
class Point {
public:
Point( float x = 0.0 ) : _x( x ) {}
float x() { return _x; }
void x( float xval ) { _x = xval; }
// ...
protected:
float _x;
};
class Point2d : public Point {
public:
Point2d( float x = 0.0, float y = 0.0 )
: Point( x ), _y( y ) {}
float y() { return _y; }
void y( float yval ) { _y = yval; }
// ...
protected:
float _y;
};
class Point3d : public Point2d {
public:
Point3d( float x = 0.0, float y = 0.0, float z = 0.0 )
: Point2d( x, y ), _z( z ) {}
float z() { return _z; }
void z( float zval ) { _z = zval; }
// ...
protected:
float _z;
};
- 模板
template < class type >
class Point3d
{
public:
Point3d( type x = 0.0,
type y = 0.0, type z = 0.0 )
: _x( x ), _y( y ), _z( z ) {}
type x() { return _x; }
void x( type xval ) { _x = xval; }
// ... etc ...
private:
type _x;
type _y;
type _z;
};
- 多参模板
template < class type, int dim >
class Point
{
public:
Point();
Point( type coords[ dim ] ) {
for ( int index = 0; index < dim; index++ )
_coords[ index ] = coords[ index ];
}
type& operator[]( int index ) {
assert( index < dim && index >= 0 );
return _coords[ index ]; }
type operator[]( int index ) const
{ /* same as non-const instance */ }
// ... etc ...
private:
type _coords[ dim ];
};
inline
template < class type, int dim >
ostream&
operator<<( ostream &os, const Point< type, dim > &pt )
{
os << "( ";
for ( int ix = 0; ix < dim-1; ix++ )
os << pt[ ix ] << ", "
os << pt[ dim–1];
os << " )";
}
----layout costs for adding encapsulation
c++的封装其实也并没有增加额外的layout costs
- data members直接包含在了每一个类对象。
- member function并不反映在这个对象的layout里。所有类生成的对象的
non-inline
成员函数只有一份拷贝。每个inline
函数在使用它的每个模块中都有零个或一个自身定义。
所以说Point3d
类支持封装并没有什么运行时间和空间的惩罚。
但主要的layout和access-time负担都和virtual
有关。
- 支持高效的运行时绑定(动态绑定)
- 一个虚拟基类,它支持在继承层次结构中出现多次的基类的单个共享实例。(基类复用)
1.1c++ object model
在c++对象中有两种类型的数据成员:static
,nonstatic
;三种成员函数:static
,nonstatic
,virtual
;如下的例子:
class Point
{
public:
Point( float xval );
virtual ~Point();
float x() const;
static int PointCount();
protected:
virtual ostream&
print( ostream &os ) const;
float _x;
static int _point_count;
};
Q:所以说这个class类在机器中时如何表示的?
----一个简单的object model
在这个简单的对象模型,所有的成员都保存为了连续的地址值。所以一个类的对象的大小等于sizeof(pointer)* number of members。
虽然这个模型不在实践中使用,但是这个简单的概念还是有其价值。
----A table-driven object model
为了能够给所有类的对象一个统一的表示引入了另一种对象模型。
每个类对象包含两个指向成员表的指针:一个指向成员函数表,表内是函数的地址;一个指向数据成员表,直接保存数据。所以每个类对象只用包含两个指针就好了。
虽然上面的model不是在c++使用,但是成员函数表的使用是支持高效运行时的虚函数解析的传统实现。
----the c++ object model
最初和流行的c++对象模型受到了优化空间使用和缩短获取时间的驱动。
- data member:
nonstatic
直接在类对象内分配;shatic
存储在每个类对象外面。
- function member:
static
保存在外部nonstatic
保存在外部virtual
分两步:1每个类生成一个指向虚函数的指针表。2每个类对象都有一个指针指向这个表(此例中是__vptr__Point
)__vptr__Point
的设置,重新设置还是不设置还是取决于构造器,析构器,和拷贝赋值。
type_info
支持运行时类型检查,通常在虚表的第一项
上面的缺点:类对象的nonstatic
数据成员的增加,修改,删除都需要重新编译。(这一点就不如上面的二表模型)
----增加继承
c++支持单继承,多继承,虚继承。
//单继承
class Library_materials { ... };
class Book : public Library_materials { ... };
class Rental_book : public Book { ... };
//多继承
// original pre-Standard iostream implementation
class iostream:public istream,public ostream { ... };
//虚继承
class istream : virtual public ios { ... };
class ostream : virtual public ios { ... };
Q:一个派生类如何model他的基类实例?
- 在simple base class object model中这很简单,只要给派生类增加一个slot,这个slot保存基类
subobject
的地址就可以了。但是缺点是这种间接性(indirection)带来的space&access-time上的损失。
subobject在虚继承中是指,在继承中基类只有一份(比如说菱形继承),比较典型的例子是
iostream
。
- 也可以试试base tatble model。一个base class table的每个slot包含相关的基类的地址。每个类对象包含
bptr
指向这个table。但是这也是indirection的。
indirection:随着继承链的延长会造成很大的开销,所以最初的c++ inheritance model放弃了所有的indirection。
- 最初的c++ inheritance model放弃了所有的indirection。但是对base class的任何改动都要重新的编译,这是一个trade-off。在2.0的版本引入了虚基类的概念需要增加indirection后来的模型陆续增加了indirection的能力。
----how the object model effects programs
Q:object model对程序员来说意味着什么呢?
为了支持对象模型对代码进行修改和增加。例如class X
定义了拷贝构造函数,虚析构器和虚函数foo()
:
X foobar()
{
X xx;
X *px = new X;
// foo() is virtual function
xx.foo();
px->foo();
delete px;
return xx;
};
他会变成下面的形式:
// Probable internal transformation
// Pseudo C++ code
void foobar( X &_result )
{
// construct _result
// _result replaces local xx ...
_result.X::X();
// expand X *px = new X;
px = _new( sizeof( X ));
if ( px != 0 )
px->X::X();
// expand xx.foo(): suppress virtual mechanism
// replace xx with _result
foo( &_result );
// expand px->foo() using virtual mechanism
( *px->_vtbl[ 2 ] )( px )
// expand delete px;
if ( px != 0 ) {
( *px->_vtbl[ 1 ] )( px ); // destructor
_delete( px );
}
// replace named return statement
// no need to destroy local object xx
return;
};
不懂也没有关系,后面会一一的解释。
1.2 A keyword distinction
应为c++努力保持对c语言的兼容性,所以会很复杂
比如为了兼容c的声明语句,需要lookahead才能区分是declaration
还是invocation
。
// don't know if declaration or invocation
// until see the integer constant 1024
int ( *pf )( 1024 );
有时lookahead也不能区分就需要meta-language rule
。
// meta-language rule:
// declaration of pq, not invocation
int ( *pq )( );
另一个例子就是struct
和class
。如果不支持c的话只需要class
就行了。
----keywords,schmeewords
struct
和class
是一致的,你甚至可以声明用class而定义struct。这点不同于static
和 extern
。
// illegal? no ... simply inconsistent
class node;
...
struct node { ... };
// illegal? yes
// declarations make contrary storage claims
static int foo;
...
extern int foo;
static:
extern:
----the politically correct struct
一些在c语言上的用法放在c++上可能是灾难
struct mumble {
/* stuff */
char pc[ 1 ];
};
// grab a string from file or standard input
// allocate memory both for struct & string
struct mumble *pmumb1 = ( struct mumble* )
malloc(sizeof(struct mumble)+strlen(string)+1);
strcpy( &mumble.pc, string );
c++放置类声明的时候不能很好的转换(内存layout
可能不按照declaration
的顺序)
- 指定多个访问权限不同的区域且包含数据(相同的访问权限可以保证顺序)
- 派生于其他类或者自己是一个派生对象
- 定义一个或多个虚函数
chapter03会认真讨论类内的layout
组合(composition)是可能仅有的可移值的方法结合c和c++类的部分:
struct C_point { ... };
class Point {
public:
operator C_point() { return _c_point; }
// ...
private:
C_point _c_point;
// ...
};
一个合理的在c++使用c风格struct
的方式是你想传递部分的类对象给c函数。
1.3 An object distinction
c++支持三种程序范式
- 像c语言的procedural model;
- ADT(abstract data type) model
- OO(object-oriented) model
OO model和ADT model的区别!
Library_materials thing1;
// class Book : public Library_materials { ...};
Book book;
// Oops: thing1 is not a Book!
// Rather, book is ``sliced'' —
// thing1 remains a Library_materials
thing1 = book;
// Oops: invokes
// Library_materials::check_in()
thing1.check_in();
// OK: thing2 now references book
Library_materials &thing2 = book;
// OK: invokes Book::check_in()
thing2.check_in();
上面thing1
和thing2
的使用就是ADT和OO的区别。
在OO编程中,对象真正的类型只有在运行时才能通过指针和引用来解析。
// represent objects: uncertain type
Library_materials *px = retrieve_some_material();
Library_materials &rx = *px;
// represents datum: no surprise
Library_materials dx = *px;
上面的例子中px
和rx
的对象(因为是指针和引用)类型是不确定的,但是dx
是确定的。
用引用和指针实现多态,但是指针和引用并不会导致多态 。如下的
pi
和pvi
就没有多态的支持。而px
有。
// no polymorphism
int *pi;
// no language supported polymorphism
void *pvi;
// ok: class x serves as a base class
x *px;
c++支持多态的三种方式:
- 隐式的转换:把派生类的指针转换为基类的指针。
shape *ps = new circle();
- 虚函数机制
ps->rotate();
dynamic_cast
和typed
操作符
if ( circle *pc =
dynamic_cast< circle* >( ps )) ...
下面的例子:
void rotate(X datum, const X *pointer, const X &reference )
{
// cannot determine until run-time
// actual instance of rotate() invoked
(*pointer).rotate();
reference.rotate();
// always invokes X::rotate()
datum.rotate();
}
main() {
Z z; // a subtype of X
rotate( z, &z, z );
return 0;
}
上面两个通过pointer
和reference
的调用是动态解析的。而对datum
的调用却不是通过虚函数机制,他永远调用X::rotate()
。
一个类对象需要的内存:
- 所有非静态数据成员
- 由于对齐限制的填充
- 支持
virtual
的负担
下面给出一个例子:
class ZooAnimal {
public:
ZooAnimal();
virtual ~ZooAnimal();
// ...
virtual void rotate();
protected:
int loc;
String name;
};
ZooAnimal za( "Zoey" );
ZooAnimal *pza = &za;
za
和pza
的内存排版(layout)如下图:
----the type of Pointer
ZooAnimal *px;
int *pi
Array< String > *pta;
上面三种指针有什么区别吗?在需要的内存都是一样的,都是一个机器word
大小。区别是这些指针值所指向的对象(也就是编译器如何解释指针所指的memory和需要解释的内存长度)。
void*
指针需要解释的内存长度是多少?我们不知道,所有说void*
只是用来保存地址,而不能对对他进行操作。
所以说类型转换只是一种编译指令。只是改变需要解释的内存的大小和组成。
----adding polymorphism
现在我们定义Bear作为ZooAnimal的一种。
class Bear : public ZooAnimal {
public:
Bear();
~Bear();
// ...
void rotate();
virtual void dance();
// ...
protected:
enum Dances { ... };
Dances dances_known;
int cell_block;
};
Bear b( "Yogi" );
Bear *pb = &b;
Bear &rb = *pb;
所以b
,pb
,rb
所需要的内存是多少?下面是对应的内存布局(layout):
Q:假如Bear
对象位于内存地址1000处,Bear
和ZooAnimal
指针的真正不同是什么?
Bear b;
ZooAnimal *pz = &b;
Bear *pb = &b;
真正的不同就是pb
指针的地址跨度是整个Bear
对象,而pz
的地址跨度是Bear
的子对象(subobject)ZooAnimal
(见上图)。
所以pz
不能获取除了ZooAnimal
子对象的任何成员,除了利用虚机制(virtual mechanism)。
// illegal: cell_block not a member
// of ZooAnimal, although we ``know''
// pz currently addresses a Bear object
pz->cell_block;
// okay: an explicit downcast
(( Bear* )pz)->cell_block;
// better: but a run-time operation
if ( Bear* pb2 = dynamic_cast< Bear* >( pz ))
pb2->cell_block;
// ok: cell_block a member of Bear
pb->cell_block;
为什么
dynamic_cast
更好?
当我们写:
pz->rotate();
pz
的类型决定了在编译时:
- 固定的可获得接口
- 接口的access level
pz
的类型信息时由vptr
和对应virtual table决定的。
Q:但是下面的如何解释呢?za
始终调用ZooAnimal::rotate()
Bear b;
ZooAnimal za = b;
// ZooAnimal::rotate() invoked
za.rotate();
如果成员都复制一份,那么为什么za
的vptr
的地址为什么没有因为复制一份而指向Bear的virtual table?
这个问题的答案是编译器保证在赋值发生的时候如果一个类包含一个或多个vptr
,那么这些vptr
的值不会被右端的对象初始化或者更改。
我们看下面的例子:
{
ZooAnimal za;
ZooAnimal *pza;
Bear b;
Panda *pp = new Panda;
pza = &b;
}
内存布局(layout)如下:
赋值pza
的地址是za
,b
和pp
不是问题!只要改变地址跨度就行了。
但是如果把一个Bear
对象赋值给za
那么就会overflow。所以说如果把派生类赋值给基类对象的话就会切片去适合对应的内存大小。派生 类的东西一点也没有。由于多态不存在了,就可以在编译时直接调用虚函数。如果虚函数是inline
的话会显著增加性能。
在继承的时候非虚函数会直接继承;这是在现在c++的,应该适合本书吧。
OB(object-based)指的是ADT 风格的–非多态数据类型如
String
类;OB设计会比OO更加快和紧凑但是不灵活(trade-off)
chap02:the semantics of constructor
c++的编译器会在背后做一些事情。例如
if ( cin ) ...
预先定义了operator int ()
这是很好的实践。但是下面的:
// oops: meant cout, not cin
cin << intVal;
编译器并不会报错,而是把<<
解释成左移运算符。
// oops: not quite what the programmer intended
int temp = cin.operator int();
temp << intVal;
后来就把opetator int()
改成了operator void* ()
。
隐式的类型转化是我们产生困惑的原因,所以引入
explicit
。
所以这一章将围绕编译器给对象构造的干预和影响展开。
2.1default constructor construction
“ 默认构造器在需要的时候被编译器生成”
class Foo { public: int val; Foo *pnext; };
void foo_bar()
{
// Oops: program needs bar's members zeroed out
Foo bar;
if ( bar.val || bar.pnext )
// ... do something
// ...
}
在这个例子中,正确的程序语义需要Foo
的默认构造器把两个成员赋值为0;但是这个例子并没有。
全局对象保证其对应的内存的值都“归零”,而在程序堆和栈上的对象还保留着原来的内存的位模式。
----Member class object with default constructor
如果一个没有构造器的类包含一个有默认构造器的类对象,这个时候隐式的默认构造函数就是非平凡的了。编译器就会生成一个默认构造器。
在c++的分离式编译模型中,怎么避免编译器合成多个默认构造器?答案是把合成的默认构造器,拷贝构造器,析构器,拷贝赋值操作符定义成
inline
的,如果函数太复杂就合成一个no-inline
的static
实例。
比如说:
class Foo { public: Foo(); Foo( int ) ... };
class Bar { public: Foo foo; char *str; };
void foo_bar() {
Bar bar; // Bar::foo must be initialized here
if ( str ) { } ...
}
注意上面的合成的默认构造函数只是保证能够调用Bar::foo
的默认构造器,但是没有产生任何初始化Bar::str
的代码。初始化Bar::foo
是编译器的责任,但是初始化Bar::str
是编程者的责任。所以合成的默认构造器类似下面:
// possible synthesis of Bar default constructor
// invoke Foo default constructor for member foo
inline
Bar::Bar()
{
// Pseudo C++ Code
foo.Foo::Foo();
}
为了简化讨论,忽略了
this
指针。
Q:如果我们如下声明了Bar
的默认构造函数,那么怎么初始化Bar::foo
呢?
// programmer defined default constructor
Bar::Bar() { str = 0; }
答案是编译器会扩充存在的构造器。如下:
// Augmented default constructor
// Pseudo C++ Code
Bar::Bar()
{
foo.Foo::Foo(); // augmented compiler code
str = 0; // explicit user code
}
如果一个类有多个成员类对象,和自己的构造器初始化数据成员,那么扩充的构造器会先按声明的顺序调用成员对象的默认构造器,然后才是自己的初始化。
----base class with default contructor
一个没有构造器的派生类的基类有默认的构造器。这时候派生类的默认构造器也是nontrivial
的。
如果有多个构造器而没有默认构造器,那么编译器会扩充每个构造器去调用必要的默认构造器。
----class with a virtual function
还有两个需要合成的默认构造函数的例外:
- 类声明或者继承一个虚函数。
- 派生类的继承链上有一个或者多个虚基类。
在两种情况下,如果缺乏声明的构造器,为了实现簿记需要合成默认的构造器。如下的例子:
class Widget {
public:
virtual void flip() = 0;
// ...
};
void flip( const Widget& widget ) { widget.flip(); }
// presuming Bell and Whistle are derived from Widget
void foo() {
Bell b; Whistle w;
flip( b );
flip( w );
}
在编译的时候有两类”扩充“:
- 生成虚表(在早期cfront的实现中是
vtbl
类,并用active
的虚函数去填充它。 - 在每一个类对象,生成一个保存了相关的
vtbl
类的地址的指针成员。
重写虚调用widget.flip()
:使用widget
的vptr和flip()
到相对应的vtbl:
// simplified transformation of virtual invocation:
widget.flip()
( * widget.vptr[ 1 ] ) ( &widget )
其中1
表示flip()
在虚表中的固定的索引,&widget
表示this
指针。
现在可以理解多态了吧(基于虚函数)
----class with a virtual base class
虚基类的实现因编译器的不同而不同,但是都是要求在运行时派生类都能获得虚基类的位置。
class X { public: int i; };
class A : public virtual X { public: int j; };
class B : public virtual X { public: double d; };
class C : public A, public B { public: int k; };
// cannot resolve location of pa->X::i at compile-time
void foo( const A* pa ) { pa->i = 1024; }
main() {
foo( new A );
foo( new C );
//...
编译器不能固定X::i
物理offset
因为pa
的类型是不确定的(因为C
的X::i
无法确定),所以编译器就把X::i
的解析放到了运行时了。
在cfront实现中,在派生类中插入指向虚基类的指针,可能的实现如下:
// possible compiler transformation
void foo( const A* pa ) { pa->__vbcX->i = 1024; }
这里_vbcX
表示编译器生成的指向虚基类的X
的指针。
所以编译器要生成默认的构造函数来产生_vbc
。
构造器就是初始化的
----summary
有四种类没有声明构造器但编译器要生成默认构造器。
在合成的默认构造函数中只有基类子对象和成员类对象被初始化,其他的如非静态数据成员都没有初始化。
c++新手有两大误区:
- 每个类如果没有定义默认构造函数会自动生成一个。
- 编译器生成的默认构造器提供每个数据成员的显式默认初始化。
2.2 copy constructor construction
有三类类对象被另一个类对象初始化的程序实例:
- 对象显示初始化
class X { ... };
X x;
// explicit initialization of one class object with
another
X xx = x;
- 函数传参
extern void foo( X x );
void bar()
{
X xx;
// implicit initialization of foo()'s
// first argument with xx
foo( xx );
// ...
}
- 函数返回
X foo_bar()
{
X xx;
// ...;
return xx;
}
假如类的设计者显示定义了拷贝构造器比如如下:
// examples of user defined copy constructors
// may be multi-argument provided each second
// and subsequent argument is provided with a
// default value
X::X( const X& x );
Y::Y( const Y& y, int = 0 );
这时候如果调用这个构造器的话,可能会导致产生临时类对象和实际的代码改变(或者兼而有之)。
----default memberwise initialization
如果不提供显示的构造函数,每个类对象通过默认成员初始化的方式被同类的另一个对象初始化。
默认成员初始化复制内置类型和派生的数据成员(比如指针和数组),但是成员类对象却不复制。
class String {
public:
// ... no explicit copy constructor
private:
char *str;
int len;
};
比如:
String noun( "book" );
String verb = noun;
默认成员初始化的语义如下:
// semantic equivalent of memberwise initialization
verb.str = noun.str;
verb.len = noun.len;
如果String
类作为其他类的成员,比如:
class Word {
public:
// ...no explicit copy constructor
private:
int _occurs;
String _word;
};
则上面的默认成员初始化会先初始化_occurs
,然后再递归的对_word
对象进行默认成员初始化。
Q:所以说这个操作再实践中是如何实现的?
在概念上,实现是通过拷贝构造器;在实践上,如果类对象有按位复制语义,一个好的编译器应该生成按位复制。
拷贝构造器也不是为每个类自动生成,而是在需要的时候,也就是说类没有展现出按位复制语义(bitwise copy sematics)。
一个类对象可以以两种方式被复制;1通过初始化(现在阐述的);2通过赋值(第五章阐述);在概念上,这两个操作通过拷贝构造器和拷贝赋值运算符。
和默认构造器一样,如果类没有声明拷贝构造器,那么就会隐式声明和定义一个。并且区分trivial
和nontrivial
拷贝构造器。只有非平凡的实例才生成。而决定是否trival
的准则是是否展现按位复制语义。
----bitwise copy sematics
#include "Word.h"
Word noun( "block" );
void foo()
{
Word verb = noun;
// ...
}
如果类的设计者定义了拷贝构造器的话那就直接调用。如果没有的如何分辨是否具有按位复制语义呢?下面的两个例子:
// declaration exhibits bitwise copy semantics
class Word {
public:
Word( const char* );
~Word() { delete [] str; }
// ...
private:
int cnt;
char *str;
};
默认的拷贝构造器不会生成,因为声明展现了按位复制语义。
这个代码段执行起来是非常糟糕的
// declaration does not exhibits bitwise copy semantics
class Word {
public:
Word( const String& ); //不是拷贝构造器,是默认构造器
~Word();
// ...
private:
int cnt;
String str;
};
其中的String
有显式声明的拷贝构造器:
class String {
public:
String( const char * );
String( const String& ); //显式拷贝构造器
~String();
// ...
};
所以编译器要合成拷贝构造器来调用成员String
对象的拷贝构造器。
现在可以理解按位复制语义的含义了吧。
// A synthesized copy constructor
// Pseudo C++ Code
inline Word::Word( const Word& wd )
{
str.String::String( wd.str );
cnt = wd.cnt;
}
----Bitwise Copy Semantics—Not!
类不展现按位复制语义的四个实例:
- 类包含一个成员类对象的拷贝构造器存在。显式的(如
String
)或隐式的(如Word
)。 - 拷贝构造器存在的类的派生类。显式的或者隐式的存在。
- 定义了一个或者多个虚函数
- 派生类的继承链上有一个或者多个虚基类。
3和4的情况有些微妙,下面阐述。
----Resetting the Virtual Table Pointer
回忆当一个类声明了一个或者多个虚函数时候在编译时期的程序扩充:
- 一个虚函数表
- 每个类对象内部的指向虚函数表的指针
为了正确的初始化vptr
,当有虚函数的时候类就不再展现出按位复制语义了。下面的例子:
class ZooAnimal {
public:
ZooAnimal();
virtual ~ZooAnimal();
virtual void animate();
virtual void draw();
// ...
private:
// data necessary for ZooAnimal's
// version of animate() and draw()
};
class Bear : public ZooAnimal {
public:
Bear();
void animate();
void draw();
virtual void dance();
// ...
private:
// data necessary for Bear's version
// of animate(), draw(), and dance()
};
相同类对象初始化可以直接按位复制(为了简便,不考虑指针成员别名)例如:
Bear yogi;
Bear winnie = yogi;
这里的yogi
的vptr
的值可以直接的复制给winnie
的,这是安全的。派生类给基类的vptr
就不安全了:
ZooAnimal franny = yogi;
这时候如果复制了,调用draw()
的时候就会调用yogi
的,显然是不正确的(前面也有说过这种类型转换)。
所以这就需要合成的ZooAnimal
拷贝构造器显式的把设置vptr
指向ZooAnimal
的虚表而不是拷贝右边的值。
----Handling the Virtual Base Class Subobject
虚基类也需要特殊的处理。不支持按位拷贝语义
每个对虚继承的实现都涉及到在运行时派生类能够获得每个虚基类子对象的位置。维护这个位置的完整性是编译器的责任。
按位拷贝语义导致位置冲突,所以编译器必须生成拷贝构造器调解这个冲突。如下:
class Raccoon : public virtual ZooAnimal {
public:
Raccoon() { /* private data initialization */ }
Raccoon( int val ) { /* private data initialization */ }
// ...
private:
// all necessary data
};
编译器生成的代码调用ZooAnimal
的默认构造器去初始化Raccoon
的vptr
,并且把raccoon的zooanimal子对象的位置作为两个raccoon构造器内的前缀。(这就是大概的过程)。
为什么不用按成员的初始化呢?虚基类的存在使得按位拷贝语义失效。并且,这个问题不是一个对象被另一个相同的对象初始化,而是一个对象被他的派生类的初始化。如:
class RedPanda : public Raccoon {
public:
RedPanda() { /* private data initialization */ }
RedPanda( int val ) { /* private data initialization */ }
// ...
private:
// all necessary data
};
下面的情况按位拷贝就行
// simple bitwise copy is sufficient
Raccoon rocky;
Raccoon little_critter = rocky;
但是下面的:
// simple bitwise copy is not sufficient
// compiler must explicitly initialize little_critter's
// virtual base class pointer/offset
RedPanda little_red;
Raccoon little_critter = little_red;
为了获得正确的初始化little_critter
,编译器必须生成拷贝构造器,插入code去初始化基类的pointer/offset(或者简单的保证不会被reset),进行必要的按成员初始化和其他的内存工作。
虚基类更加详细的讨论在3.4节
下面的例子编译器也不知道是否拥有按位复制语义:
// simple bitwise copy may or may not be sufficient
Raccoon *ptr;
Raccoon little_critter = *ptr;
因为不清楚raccoon ptr是指向派生类还是raccoon类。
这里有一个有趣的问题:如果编译器可以保证对象的正确等效初始化,那么在存在按位复制语义的初始化时,是否应该通过抑制拷贝构造函数的调用来优化其代码生成?至少在合成拷贝构造函数的情况下,程序副作用的可能性为零,并且优化看起来很有意义。如果是由类设计器显式提供的拷贝构造函数呢?这实际上是一个颇有争议的问题。
下一节回到这个问题。
2.3Program transformation semantics
#include "X.h"
X foo()
{
X xx;
// ...
return xx;
}
可能会有下面的断言:
- 每次对
foo()
的调用都会按值返回xx
- 如果类
X
定义了拷贝构造器,那么每次对foo()
的调用都会调用拷贝构造器。
但是真相都不是上面的。
断言1的正确性取决于类X
的定义。断言2的正确性虽然部分取决于类X
的定义,但主要取决于c++编译器提供的积极优化的程度。
颠倒过来,人们甚至可以断言,在高质量的c++实现中,对于类X
的nontrivial
定义,这两个断言总是为假。
本小节的其余部分解释了原因。
----Explicit Initialization
给定定义
X x0;
下面的三个定义都用类对象x0
显示的初始化:
void foo_bar() {
X x1( x0 );
X x2 = x0;
X x3 = x( x0 );
// ...
}
所需要的程序转换是双重的。
- 每个定义都重写,去除了初始化
- 插入对类拷贝构造函数的调用
上面的程序大概变成了下面:
// Possible program transformation
// Pseudo C++ Code
void foo_bar() {
X x1;
X x2;
X x3;
// compiler inserted invocations
// of copy constructor for X
x1.X::X( x0 );
x2.X::X( x0 );
x3.X::X( x0 ); //表示拷贝构造器X::X( const X& xx )
// ...
}
----Argument Initialization
标准规定(第8.5节)将类对象作为参数传递给函数(或作为该函数的返回值)等同于以下形式的初始化:
X xx = arg;
其中xx
是形参,而arg
是实参。因此给定函数
void foo( X x0 );
调用就具有下面的形式
X xx;
// ...
foo( xx );
需要局部实例x0
被xx
按成员初始化。
一个实现策略是引入临时对象,然后调用拷贝构造器去初始化它,再把这个临时对象传给函数。例如下面的伪代码段就是可能的变换。
// Pseudo C++ code
// compiler generated temporary
X __temp0;
// compiler invocation of copy constructor
__temp0.X::X ( xx );
// rewrite function call to take temporary
foo( __temp0 );
但是这个转换只完成了一半。你能发现存在的问题吗?也就是给定了foo()
的声明,临时对象先是被X
的拷贝构造函数正确的初始化,然后又按位复制给了局部实例x0
。解决这个问题的方法就是改变函数声明:
void foo( X& x0 );
如果类X
定义了析构函数,那么在结束对foo()
的调用后就会对临时对象调用析构器。
另一个可选的实现是:将构造函数的实际实参直接拷贝构造到程序堆栈上函数的激活记录中的位置。在函数返回之前如果定义了析构器,就调用局部对象的析构器。
----Return Value Initialization
X bar()
{
X xx;
// process xx ...
return xx;
}
bar()
的返回值是如何从它的局部对象xx
中复制构造的?cfront的解决方案是一个双重转换:
- 向类对象添加引用类型的附加参数。这个实参将保存复制构造的“返回值”。
- 在return语句之前插入对复制构造函数的调用,用返回的对象的值初始化添加的参数。
对应的伪代码如下:
// function transformation to reflect
// application of copy constructor
// Pseudo C++ Code
void
bar( X& __result )
{
X xx;
// compiler generated invocation
// of default constructor
xx.X::X();
// ... process xx
// compiler generated invocation
// of copy constructor
__result.X::X( xx );
return;
}
根据上面的解释就有了但我们写下:
X xx = bar();
就会转换成(这里做了一层的优化):
// note: no default constructor applied
X xx;
bar( xx );
当我们写下:
bar().memfunc();
会被转化成:
// compiler generated temporary
X __temp0;
( bar( __temp0 ), __temp0 ).memfunc();
同样的程序声明一个函数指针:
X ( *pf )();
pf = bar;
转换为:
void ( *pf )( X& );
pf = bar;
----Optimization at the User Level
通过给函数定义一个“计算的”构造器去优化如bar()
这样的函数。也就是说不同于程序:
than the programmer's writing
X bar( const T &y, const T &z )
{
X xx;
// ... process xx using y and z
return xx;
}
需要xx
按成员复制到编译器生成的__result
。janathan(发现者)定义一个辅助构造器计算直接计算xx
:
X bar( const T &y, const T &z )
{
return X( y, z );
}
转换过来就会很高效:
// Pseudo C++ Code
void
bar( X &__result )
{
__result.X::X( y, z );
return;
}
因为是__result
是直接计算而不是调用拷贝构造函数。
这个能够实现的原因是都是引用都在参数和返回值都已经再函数外部定义好了。只需要在外部完成计算。
然而,对这种解决方案的一个批评是,可能会大量使用专门的计算构造函数。(在这个层次上的类设计更多地受到效率问题的驱动,而不是受到类打算支持的底层抽象的驱动。)
----Optimization at the Compiler Level
在函数bar()
中所有的返回语句返回相同命名的值,所以就可以用result
替代。比如:
X bar()
{
X xx;
// ... process xx
return xx;
}
用result
替代xx
:
void
bar( X &__result )
{
// default constructor invocation
// Pseudo C++ Code
__result.X::X();
// ... process in __result directly
return;
}
这种编译器优化,有时被称为命名返回值(Named Return Value, NRV)优化,在ARM第12.1.1c节中有描述(第300-303页)。NRV优化现在被认为是一个强制性的标准c++编译器优化,尽管这个要求当然不属于正式的标准。要获得性能增益的感觉,请考虑以下类:
class test {
friend test foo( double );
public:
test()
{ memset( array, 0, 100*sizeof( double )); }
private:
double array[ 100 ];
};
friend
考虑下面的函数,它创建、修改并返回一个test
类对象:
test foo( double val )
{
test local;
local.array[ 0 ] = val;
local.array[ 99 ] = val;
return local;
}
main()例程调用该函数1000万次:
main()
{
for ( int cnt = 0; cnt < 10000000; cnt++ )
{ test t = foo( double( cnt )); }
return 0;
}
程序的第一个版本没有应用NRV优化,因为没test
类的拷贝构造函数。第二个版本增加了一个内联拷贝构造函数:
inline
test::test( const test &t )
{
memcpy( this, &t, sizeof( test ));
}
拷贝构造函数的存在“开启”c++编译器的NRV优化。时间会缩短一半。
虽然NRV性能提升但是对这种方法有一些批评。
- 一个原因是,因为优化是由编译器静默地完成的,所以它是否被实际执行并不总是很清楚
(特别是因为很少有编译器记录它的实现程度,或者它是否被实现)。 - 第二个是随着函数变得越来越复杂,优化变得越来越难以应用。
例如,在cfront中,只有当所有命名的返回语句都出现在顶层函数时,才会应用优化。引入一个带有return语句的嵌套局部块,cfront悄悄地关闭优化。争论这种情况的程序员建议使用专门的构造函数策略。
- 这两个批评涉及到编译器可能无法应用优化。第三种批评采取了相反的立场:一些程序员实际上批评了优化的应用。
假如你已经利用拷贝构造器的特性(自己定义了拷贝构造器)使应用程序依赖于对通过拷贝构造器去初始化的对象然后去调用析构函数对称性。比如说:
void foo()
{
// copy constructor expected here
X xx = bar();
// ...
// destructor invoked here
}
那么使用优化这个程序会崩溃。
Q:整个程序在任何对象需要通过拷贝初始化对象的时候是否一定要调用拷贝构造器?
这样的要求会带来性能损失。比如说下面的三个初始化时语义等价的:
X xx0( 1024 );
X xx1 = X( 1024 );
X xx2 = ( X ) 1024;
在第二个和第三个例子,这个语法显式的需要两步初始化:
- 用
1024
初始化临时对象 - 用临时对象拷贝构造实际的对象
也就是说xx0
:
// Pseudo C++ Code
xx0.X::X( 1024 );
如果严格的按照语句来实现xx1
和xx2
:
// Pseudo C++ Code
X __temp0;
__temp0.X::X( 1024 );
xx1.X::X( __temp0 );
__temp0.X::~X();
标准委员会一直在讨论取消复制构造函数调用的合法性。在撰写本书时,它还没有做出最终决定。 Josee Lajoie认为NRV优化被认为太重要了不能被拒绝。
显然,这场争论已经发展到两个有点深奥的情况:拷贝静态和局部对象时候拷贝构造函数的消除是否也应该允许。例如,给定以下代码片段:
Thing outer;
{
// can inner be eliminated?
Thing inner( outer );
}
inner
应该是从outer
拷贝构造还是可以简单地消除inner
?
(这个问题也可以类似地问关于static
和extern
象的拷贝初始化的问题。)Josee认为,消除静态对象的拷贝构造函数几乎肯定是不允许的。然而,自动对象(如inner
)的结果仍未解决。
一般来说,语言允许编译器在用一个类对象初始化另一个类对象方面有很大的回旋余地。当然,这样做的好处是显著提高了代码生成的效率。缺点是您不能安全地定制你的拷贝构造函数中并依赖于它们的执行。
----The Copy Constructor: To Have or To Have Not?
class Point3d {
public:
Point3d( float x, float y, float z );
// ...
private:
float _x, _y, _z;
};
Q:是否类的设计者应该提供显式的拷贝构造器?
这个类的默认构造器是trivial
的。所以该类的类对象被另一方按成员初始化会导致按位拷贝。这很高效,但是安全吗?但是是很安全,因为三个坐标成员按值存储,按位拷贝既不会导致内存泄漏也不会导致地址别名。所以在本例中不需要显式的拷贝构造器。
更微妙的答案是问您是否设想类需要大量的成员初始化,特别是按值返回对象?如果答案是肯定的,那么提供拷贝构造函数的显式内联实例就非常有意义——也就是说,假设你的编译器提供NRV优化。
假如Point3d
类提供下面的函数集:
Point3d operator+( const Point3d&, const Point3d& );
Point3d operator-( const Point3d&, const Point3d& );
Point3d operator*( const Point3d&, int );
etc.
这些都很适合NRV模板:
{
Point3d result;
// compute result
return result
}
最简单的声明拷贝构造器的方式是:
Point3d::Point3d( const Point3d &rhs )
{
_x = rhs._x;
_y = rhs._y;
_z = rhs._z;
};
但使用c libmemcpy()
更加的高效:
Point3d::Point3d( const Point3d &rhs )
{
memcpy( this, &rhs, sizeof( Point3d );
};
然而,只有当类不包含任何编译器生成的内部成员时,使用memcpy()
和memset()
才有效。如果Point3d
类声明了一个或多个虚函数或包含虚基类,那么使用这些函数中的任何一个都将导致重写编译器为这些成员设置的值。
例如,给定以下声明:
class Shape {
public:
// oops: this will overwrite internal vptr!
Shape() { memset( this, 0, sizeof( Shape ));
virtual ~Shape();
// ...
};
会生成下面的代码:
// Expansion of constructor
// Pseudo C++ Code
Shape::Shape()
{
// vptr must be set before user code executes
__vptr__Shape = __vtbl__Shape;
// oops: memset zeros out value of vptr
memset( this, 0, sizeof( Shape ));
};
正如你所看到的,正确使用memset()
和memcpy()
函数需要一些c++ object model sematics的知识!
2.4Member initialization list
在编写构造函数时,可以选择通过成员初始化列表或在构造函数体中初始化类成员。除了四种情况,你选择哪一种并不重要。
为了使程序编译,在下列情况下必须使用成员初始化列表:
- 初始化引用成员。
- 初始化const成员。
- 用一组参数调用基类或成员类构造函数。
而第四种情况程序会正确的编译和执行但是没有效率,比如:
class Word {
String _name;
int _cnt;
public:
// not wrong, just naive ...
Word() {
_name = 0;
_cnt = 0;
}
};
这会导致Word
的构造器初始化_name
后然后用赋值重载初始化,到这里临时String
对象的创造和析构,构造器像下面这样扩张:
// Pseudo C++ Code
Word::Word( /* this pointer goes here */ )
{
// invoke default String constructor
_name.String::String();
// generate temporary
String temp = String( 0 );
// memberwise copy _name
_name.String::operator=( temp );
// destroy temporary
temp.String::~String();
_cnt = 0;
}
更好的实现是:
// preferred implementation
Word::Word : _name( 0 )
{
_cnt = 0;
}
扩充就像下面这样:
// Pseudo C++ Code
Word::Word( /* this pointer goes here */ )
{
// invoke String( int ) constructor
_name.String::String( 0 );
_cnt = 0;
}
这个陷阱经常在下面的模板代码发生的那样:
template < class type >
foo< type >::foo( type t )
{
// may or may not be a good idea
// depending on the actual type of type
_t = t;
}
所以导致了很多程序员全部用成员列表初始化所有的成员。甚至本来就表现好的_cnt
:
// some insist on this coding style
Word::Word(): _cnt( 0 ), _name( 0 )
{}
所以说更合理的问题是:在使用成员初始化列表的时候到底发生了什么?
编译器在初始化列表迭代,在构造器内先于显式的用户代码去插入合适顺寻的初始化代码。比如说先前的Word
构造器扩充如下:
// Pseudo C++ Code
Word::Word( /* this pointer goes here */ )
{
_name.String::String( 0 );
_cnt = 0;
}+
它看起来与在构造函数体中赋值_cnt时完全相同。实际上,这里有一个微妙之处需要注意:列表项的设置顺序是由类声明中成员的声明顺序决定的,而不是初始化列表中的顺序。在这种情况下,
在Word中,_name声明在_cnt之前,因此放在第一位。所以下面的错误就不难理解了:
class X {
int i;
int j;
public:
// oops! do you see the problem?
X( int val )
: j( val ), i( j )
{}
...
};
所以在实践中喜欢把用一个成员初始化另一个成员的代码放在构造器内部:
// preferred idiom
X::X( int val )
: j( val )
{
i = j;
}
Q:初始化列表中的条目是否保留了类的声明顺序?
如果保留上面的代码也是错误的。所以说上面的代码时正确的,因为初始化列表的条目放置在显式的用户代码之前。
另一个问题是是否能调用了成员函数去初始化成员,如:
// is the invocation of X::xfoo() ok?
X::X( int val )
: i( xfoo( val )),j( val )
{}
答案时能,但是还是强烈建议放在函数体内。成员函数的使用是有效的(除了它所访问的成员是否已初始化的问题)。这是因为与要构造的对象相关联的this
指针是格式良好的,并且展开的形式简单如下:
// Pseudo C++ Code: constructor augmentation
X::X( /* this pointer, */ int val )
{
i = this->xfoo( val );
j = val;
}
最后就是基类构造器调用派生类的成员函数去传递参数:
// is the invocation of FooBar::fval() ok?
class FooBar : public X {
int _fval;
public:
int fval() { return _fval; }
FooBar( int val )
: _fval( val ),
X( fval() )
{}
...
};
你认为是好还是不好?下面是可能的展开:
// Pseudo C++ Code
FooBar::FooBar( /* this pointer goes here */ )
{
// Oops: definitely not a good idea
X::X( this, this->fval() );
_fval = val;
};
实在是不好的idea(顺序重排了)。
后面的章节再解释其他的初始化成员列表
总结:编译器遍历并可能重新排序初始化列表,以反映成员的声明顺序。它在任何显式用户代码之前将代码插入构造函数的主体中。
chap03:the sematics of data
来信的问题:
class X {};
class Y : public virtual X {};
class Z : public virtual X {};
class A : public Y, public Z {};
没有包含任何的数据,只有继承关系,使用sizeof
后:
sizeof X yielded 1
sizeof Y yielded 8
sizeof Z yielded 8
sizeof A yielded 12
Q:按道理来说应该为0,但上面这是为什么呢?
// sizeof X == 1
class X {};
他被编译器插入了一个字节,且会被允许两个类的对象会有相同的地址:
X a, b;
if ( &a == &b ) cerr << "yipes!" << endl;
而类Y
和类Z
:
// sizeof Y == sizeof Z == 8
class Y : public virtual X{};
class Z : public virtual X{};
这个大小是由机器和编译器实现决定的,是由下面三个因素互相影响:
- 对语言支持的开销:虚基类的派生类通过指针来支持虚基类子对象或存储了虚基类子对象address和offset的相关table。
- 编译器对识别的特殊情况的优化:虚基类
X
子对象的1字节大小也出现在Y
(和Z
)中。传统上,它被放置在派生类的“固定”(即不变)部分的末尾。一些编译器现在提供了对空虚基类的特殊支持(后面的段落将对此进行更详细的讨论)。来信者的编译器并不支持这种优化。 - 对齐限制:此时类
Y
(和Z
)的大小为5字节,但是最后还是许哟啊8字节来存储。
空的虚拟基类已经成为c++下OO设计的常用习惯用法(它提供了一个不定义任何数据的虚拟接口)。作为回应,最近的一些编译器提供了对空虚基类的特殊处理(参见[SUN94a])。
在这种策略下,空虚基类在派生类的开头被一致性的对待;也就是说,它不占用额外的空间。
这节省了与2相关的1个字节。这种节省反过来又消除了3中所需的3字节填充的需要。支持虚派生的开销(1中)仍在,这个模型下Y
和Z
的大小都是4个字节,不是8个。
编译器之间的这种潜在差异说明了c++对象模型的进化性质。该模型提供了一般情况。随着时间的推移,随着特殊情况的识别,引入这种或那种探索来提供最佳处理。如果成功,则将探索提升为通用实践,并在各个实现中合并。它被认为是标准,尽管它没有被标准规定,随着时间的推移,它可能被认为是语言的一部分。虚函数表就是一个很好的例子。另一个是第二章的NRV。
那A
类的大小是多少呢?
明显的部分取决于编译器,首先我们假设编译器对虚基类没有做特别的优化,那么X
和Y
的大小是8字节,而A
确是12字节而不是16字节:
无论继承发生了多少次,虚基类子对象只能在其派生类中出现一次。A
的大小取决于:
- 单个共享的
X
的实例的大小:1字节 - 其基类
Y
和Z
的大小减去为类X
分配的存储空间:每个4字节(总共8字节) - 类
A
本身的大小:在本例中为0字节 A
类的对齐要求(如有)。不对齐的大小为9字节。类A
必须在4字节的边界上对齐,因此它需要3字节的填充。这导致总大小为12字节。
如果加上编译器对空虚基类的优化,那么就是8个字节而不是12个。
c++标准没有强制要求诸如基类子对象或跨访问级别的数据成员排序之类的细节。它也不强制实现虚函数或虚基类;相反,它声明它们是依赖于实现的。在本章和本书其余部分的讨论中,我将区分标准的要求和当前的实践。
在本章中,类的数据成员和类的层次结构是主要内容。大体上来讲:类的数据成员通常表示程序执行过程中某个时刻该类的状态(状态机的概念)。非静态数据成员保存单个类对象的值;静态数据成员持有整个类感兴趣的值。
c++对象模型的非静态数据的直接获取兼顾了space和access-time和对c的struct
的相容性。这样包括了继承的虚或非虚基类的nonstatic
数据成员(然而他们的layout
并未定义)。static
数据成员在程序的全局数据部分维护,并且只维护一份(不管有多少对象和派生对象)。
模板类的
static
数据就不一样了
类对象的实际的大小可能超过了所要包含的nonstatic
成员的大小。可能由于:
- 编译系统为支持某些语言功能而添加的额外数据成员(主要是虚拟成员)
- 数据成员和数据结构作为一个整体的对齐要求
3.1The Binding of a Data Member
考虑下面的代码段:
// A third party foo.h header file
// pulled in from somewhere
extern float x;
// the programmer's Point3d.h file
class Point3d
{
public:
Point3d( float, float, float );
// question: which x is returned and set?
float X() const { return x; }
void X( float new_x ) const { x = new_x; }
// ...
private:
float x, y, z;
};
Q:X()
返回哪一个x
,类内部的还是外部的。
回答类内部不总是正确的。
在最初的c++实现中,上面两个fPoint3d::X()
对x
的引用会被解析成对全局x
对象!这就导致早前的两种防御性编程风格:
- 把数据成员放在前面保证正确的绑定:
class Point3d
{
// defensive programming style #1
// place all data first ...
float x, y, z;
public:
float X() const { return x; }
// ... etc. ...
};
在类内声明且定义的函数是默认
inline
,而声明与实现分离的化就不默认是inline
的了,如果在头文件声明了在源文件要想内联就显式的加上inline
。
- 在类外声明
inline
的实现,不管函数的大小。
class Point3d
{
public:
// defensive programming style #2
// place all inlines outside the class
Point3d();
float X() const;
void X( float ) const;
// ... etc. ...
};
inline float
Point3d::
X() const
{
return x;
}
// ... etc. ...
在后来的2.0版本后就不需要这种风格了,因为定义了成员重写规则,所有的内联函数在类声明被看到。如下:
extern int x;
class Point3d
{
public:
...
// analysis of function body delayed until
// closing brace of class declaration seen.
float X() const { return x; } //默认是inline的
...
private:
float x;
...
};
// in effect, analysis is done here
但是对于成员函数的参数列表就不正确了。
typedef int length;
class Point3d
{
public:
// oops: length resolves to global
// ok: _val resolves to Point3d::_val
mumble( length val ) { _val = val; }
length mumble() { return _val; }
// ...
private:
// length must be seen before its first
// reference within the class. This
// declaration makes the prior reference illegal.
typedef float length;
length _val;
// ...
};
所以我们还是需要防御性的编程风格把嵌套在类内部的length
的定义提前到类成员函数之前。
3.2Data Member Layout
class Point3d {
public:
// ...
private:
float x;
static List<Point3d*> *freeList;
float y;
static const int chunkSize = 250;
float z;
};
类内只有nonstaic
成员,顺序是x
, y
, z
。标准只要求后面的数据有更高的地址,可以有因对齐限制而有的填充。
此外编译器也会合成其他的内部数据成员比如vptr
。传统的是放在显式声明成员的后面,后来就变到最前面了。标准并没有做出限制,你放哪里都可以。
标准还让编译器随便放置不同访问权限的成员 。比如下面的类声明:
class Point3d {
public:
// ...
private:
float x;
static List<Point3d*> *freeList;
private:
float y;
static const int chunkSize = 250;
private:
float z;
};
在实践中,多个不同访问权限的区间根据声明的顺序合成连续的块。没有任何的多余开销。
如何区分不同的访问权限的?
下面的模板函数来展示哪个放置的更前面
指向类成员的指针见3.6节
template< class class_type,
class data_type1,
class data_type2 >
char*
access_order(
data_type1 class_type::*mem1,
data_type2 class_type::*mem2 )
{
assert ( mem1 != mem2 );
return
mem1 < mem2
? "member 1 occurs first"
: "member 2 occurs first";
}
调用:
access_order( &Point3d::z, &Point3d::y );
3.3Access of a Data Member
Point3d origin;
origin.x = 0.0;
Q:获取x
的代价是什么?
分为不同情况:
x
是static
还是nonstatic
的?- 类是单独的类?还是继承普通的类?还是多继承?还是虚继承?
在开始阐述之前,另一个问题,指针获取和直接获取有没有区别?
Point3d origin, *pt = &origin;
//指针获取和直接获取
origin.x = 0.0;
pt->x = 0.0;
后面再解答这个问题。
----Static Data Members
static数据成员就像全局变量一样,但是只对类作用域可见。维护每个成员的访问权限和与类的关联不会在单个类对象或静态数据成员本身中产生任何空间或运行时开销。
// origin.chunkSize == 250;
Point3d::chunkSize == 250;
// pt->chunkSize == 250;
Point3d::chunkSize == 250;
上面的例子可以看到,不管类对象是origin
还是*pt
都只需要调Point3d::chunkSize
就行。这也是唯一的通过指针获取和直接获取等价的情况。
如果chunkSize
是通过复杂的继承层次的继承成员,那也没有什么影响。
下面的表达式该如何理解?
foobar().chunkSize == 250;
其实函数的调用并没什么卵用?因为是静态的,可能的转换如下:
// foobar().chunkSize == 250;
// evaluate expression, discarding result
(void) foobar();
Point3d::chunkSize == 250;
获取静态数据成员的地址会产生一个普通的数据类型指针,而不是指向类成员的指针,因为静态成员不包含在类对象中。例如&Point3d::chunkSize;
参数的实际地址类型是const int*。
如果两个类各自声明一个静态成员freeList
,那么将它们都放在程序数据段中将导致名称冲突。编译器通过对每个静态数据成员的名称进行内部编码来解决这个问题——它被亲切地称为名称篡改——以产生唯一的程序标识符。
----Nonstatic Data Members
nonstatic
数据成员直接存储在每个类对象中,除非通过显式或隐式类对象,否则不能访问。
Point3d
Point3d::translate( const Point3d &pt ) {
x += pt.x;
y += pt.y;
z += pt.z;
}
x
,y
,z
通过this
指针来访问隐式的类对象。等价于:
// internal augmentation of member function
Point3d
Point3d::translate( Point3d *const this, const Point3d &pt ) {
this->x += pt.x;
this->y += pt.y;
this->z += pt.z;
}
获得非静态数据成员的地址需要起始地址和offset,比如:
origin._y = 0.0;
的地址&origin._y;
等价于&origin + ( &Point3d::_y - 1 );
。注意与静态的区别。
这里为什么减1呢?后面3.6再讨论。
无论成员是在基类子对象还是多个继承链,offset在编译的时候就知道了。获取非静态对象的性能只有在虚继承的时候有indirection的时候会有损失。
origin.x = 0.0;
pt->x = 0.0;
所以回到前面的问题只有虚继承的时候pt
在编译时不知道数据成员的offset,需要通过间接的获取。
继承的时候基类和派生类有同名的数据成员咋办?
3.4Inheritance and the Data Member
标准并未规定在c++继承模型中派生类中基类子对象和派生类数据成员的顺序;在实践中是把基类成员放在前面(除了虚基类)。
Q:表示二维和三维点的抽象数据类型有什么区别?
// supporting abstract data types
class Point2d {
public:
// constructor(s)
// operations
// access functions
private:
float x, y;
};
class Point3d {
public:
// constructor(s)
// operations
// access functions
private:
float x, y, z;
};
如果是像上面这样独立的,那么layout就像:
----Inheritance without Polymorphism
class Point2d {
public:
Point2d( float x = 0.0, float y = 0.0 )
: _x( x ), _y( y ) {};
float x() { return _x; }
float y() { return _y; }
void x( float newX ) { _x = newX; }
void y( float newY ) { _y = newY; }
void operator+=( const Point2d& rhs ) {
_x += rhs.x();
_y += rhs.y();
}
// ... more members
protected:
float _x, _y;
};
// inheritance from concrete class
class Point3d : public Point2d {
public:
Point3d( float x = 0.0, float y = 0.0, float z = 0.0 )
: Point2d( x, y ), _z( z ) {};
float z() { return _z; }
void z( float newZ ) { _z = newZ; }
void operator+=( const Point3d& rhs ) {
Point2d::operator+=( rhs );
_z += rhs.z();
}
// ... more members
protected:
float _z;
};
如果采取上面的设计的化对应的内存布局:
Q:在把两个独立的类设计成这种继承关系的陷阱?
- 简单的设计可能会使执行相同操作的函数调用次数增加一倍。也就是说,假设我们示例中的构造函数或
operator+=()
没有内联(或者编译器由于某种原因不能支持成员函数的内联)。
初始化或添加Point3d对象的代价将是产生局部Point2d和Point3d实例。 - 将类分解为两层或更深层次结构的第二个可能的缺陷是,将抽象表示为类层次结构所需的空间可能会膨胀。下面的例子解释它
class Concrete {
public:
// ...
private:
int val;
char c1;
char c2;
char c3;
};
每个类对象需要8字节,而设计成下面:
class Concrete1 {
public:
// ...
protected:
int val;
char bit1;
};
class Concrete2 : public Concrete1 {
public:
// ...
protected:
char bit2;
};
class Concrete3 : public Concrete2 {
public:
// ...
protected:
char bit3;
};
Concrete3
类对象需要16字节。因为子对象都需要内存对齐,所以膨胀了。
为什么要这样这样设计呢?
Concrete2 *pc2;
Concrete1 *pc1_1, *pc2_2;//pc1_1可以表示所有子类的地址
//执行按成员
*pc1_1 = *pc2_2;
应该对所寻址对象的Concrete1部分执行默认的按成员复制。如果pc1_1指向一个Concrete2或Concrete3对象,则这对其Concrete1子对象的赋值不应产生影响。而如果不对齐的化
如:
pc1_1 = pc2;
// oops: derived class subobject is overridden
// its bit2 member now has an undefined value
*pc1_1 = *pc2_2;
就会覆写产生错误。
----Adding Polymorphism
class Point2d {
public:
Point2d( float x = 0.0, float y = 0.0 )
: _x( x ), _y( y ) {};
// access functions for x & y same as above
// invariant across type: not made virtual
// add placeholders for z — do nothing ...
virtual float z(){ return 0.0 };
virtual void z( float ) {}
// turn type explicit operations virtual
virtual void
operator+=( const Point2d& rhs ) {
_x += rhs.x(); _y += rhs.y(); }
// ... more members
protected:
float _x, _y;
};
这只有在我们想多态的操作两或三维的点的时候这样设计才是有意义的:
void foo( Point2d &p1, Point2d &p2 ) {
// ...
p1 += p2;
// ...
}
p1
和p2
既可以是二维也可以是三维。这虽然很灵活但是增加获取point2d的space和access-time损失:
- 引入一个与Point2d相关联的虚表,用于保存它声明的每个虚函数的地址。该表的大小通常是声明的虚函数的数量加上支持运行时类型标识的一个或两个slot。
- 在每个类对象中引入
vptr
。vptr
为对象提供运行时链接,以便有效地找到与之相关的虚拟表。 - 构造函数的扩充,将对象的
vptr
初始化为类的虚表。根据编译器优化的力度,这可能意味着在派生类和每个基类构造函数中重置vptr。(这将在第5章进行更详细的讨论。) - 扩充析构函数,将
vptr
重置为类的相关虚表。(它很可能在派生类的析构器内设置派生类的虚表地址。请记住,析构函数调用的顺序是相反的:派生类然后是基类。)一个积极的优化编译器可以抑制很多这样的赋值。
下面是新的Point3d
派生类:
class Point3d : public Point2d {
public:
Point3d( float x = 0.0, float y = 0.0, float z = 0.0 )
: Point2d( x, y ), _z( z ) {};
float z() { return _z; }
void z( float newZ ) { _z = newZ; }
void operator+=( const Point2d& rhs ) {
Point2d::operator+=( rhs );
_z += rhs.z();
}
// ... more members
protected:
float _z;
};
这有着很大的不同,两个z()
和operator+=()
是虚实例。每个point3d类都有继承下来的vptr
成员。也有一个point3d的虚表;虚函数的调用很复杂(在第四章详细说明)
struct no_virts {
int d1, d2;
};
class has_virts: public no_virts {
public:
virtual void foo();
// ...
private:
int d3;
};
no_virts *p = new has_virts;
这是最初的有c风格的layout
后来随着多继承和抽象基类和OO模式的流行,到了2.0后就变成了:
上面对虚函数的支持更好,直接就可以获得vptr的位置,但是和c不太相容。
下图显示了添加了虚函数的Point2d和Point3d继承布局。(注意:图中vptr在基类末尾的位置。)
----Multiple Inheritance
可以看到上面有两种针对虚函数的优化方式,第二种没有很好的相容性,所以在有虚函数的派生类给基类赋值的时候编译器会参与优化(不能像第一张那种直接按位拷贝)。
多继承的复杂性就更高了;
class Point2d {
public:
// ...
protected:
float _x, _y;
};
class Vertex {
public:
// ...
protected:
Vertex *next;
};
class Vertex2d :
public Point2d, public Vertex {
public:
//...
protected:
float mumble;
};
Q:多个子对象如何安排?虚函数机制怎么实现(多个虚函数表)?
假如:
Vertex3d v3d;
Vertex *pv;
Point2d *pp;
Point3d *p3d;
然后赋值pv = &v3d;
的变换相当于:
// Pseudo C++ Code
pv = (Vertex*)(((char*)&v3d) + sizeof( Point3d ));
至于:
pp = &v3d;
p3d = &v3d;
只需要地址拷贝一下就行。
而:
Vertex3d *p3d;
Vertex *pv;
对于赋值pv = p3d;
需要特殊处理:
// Pseudo C++ Code
pv = p3d
? (Vertex*)((char*)p3d) + sizeof( Point3d )
: 0;
因为指针p3d
可能被置为0,所以需要条件判断,而引用就不需要了(引用不能指向空对象)。
标准并不要求对point3d和point3d的基类进行特定的排序。有些编译器会做特别的优化。
----Virtual Inheritance
经典的iostream
库的实现:
class ios { ... };
class istream : public virtual ios { ... };
class ostream : public virtual ios { ... };
class iostream :
public istream, public ostream { ... };
挑战是既要完成设计目标又要在基类和派生类的指针或者引用的多态赋值。
主要的思想是虚基类子对象如istream
划分共享域和不变域。,共享域内的成员需要间接访问。不同实现的不同之处在于间接访问的方法。下面的例子说明了三种主要的策略。如下的例子:
class Point2d {
public:
...
protected:
float _x, _y;
};
class Vertex : public virtual Point2d {
public:
...
protected:
Vertex *next;
};
class Point3d : public virtual Point2d {
public:
...
protected:
float _z;
};
class Vertex3d :
public Point3d, public Vertex {
public:
...
protected:
float mumble;
};
通用的layout策略是先放置不变域,再放置共享域。
如何获得对类的共享域的访问?在最初的cfront实现中,在每个派生类对象中插入一个指向每个虚基类的指针。对继承的虚基类成员的访问是通过关联指针间接实现的。
例如:
void
Point3d::
operator+=( const Point3d &rhs )
{
_x += rhs._x;
_y += rhs._y;
_z += rhs._z;
};
就会转变为:
// Pseudo C++ Code
__vbcPoint2d->_x += rhs.__vbcPoint2d->_x;
__vbcPoint2d->_y += rhs.__vbcPoint2d->_y;
_z += rhs._z;
派生类和基类的转换:
Vertex *pv = pv3d;
会转变为:
// Pseudo C++ code
Vertex *pv = pv3d ? pv3d->__vbcPoint2d : 0;
但是这种实现会有两种缺点:
- 我们希望类对象的开销继承层次结构中虚拟基类的数量无关
- 随着虚继承链的加长,会导致多层的indirection。
解决第二种方法就是把指针对应的子对象拷贝过来,如下的
pointer-to-base-class实现模型:
解决第一个问题有两种办法,一是通过虚基类表;二是在虚函数表内放置虚基类的offset而不是地址。通过地址的正负来区分是虚函数还是虚基类offset。如下图:
将Point3d operator转换为以下一般形式:
// Pseudo C++ Code
(this + __vptr__Point3d[-1])->_x +=
(&rhs + rhs.__vptr__Point3d[-1])->_x;
(this + __vptr__Point3d[-1])->_y +=
(&rhs + rhs.__vptr__Point3d[-1])->_y;
_z += rhs._z;
如果没有虚表咋办,怎么实现上面的策略啊
而对如下的转换:Vertex *pv = pv3d;
有:
// Pseudo C++ code
Vertex *pv = pv3d
? pv3d + pv3d->__vptr__Point3d[-1])
: 0;
这些实现模型都不是标准所要求的,
通过非多态类对象访问继承的虚基类成员如:
Point3d origin;
...
origin._x;
可以被被优化成直接成员获取(就像虚函数在编译期被解析)对象的类型不能在一次程序访问和下一次程序访问之间改变,因此在这种情况下虚基类子对象波动的问题不成立。
虚基类最有效的用法是没有相关数据成员的抽象虚基类
3.5Object Member Efficiency
就实际的程序性能而言,这里重要的一点是,启用优化后,封装和内联访问函数的使用没有表现出运行时性能损失。
下面也讨论继承的效率
3.6Pointer to Data Members
当你想要探究内在的类成员layout的时候,在3.2节的时候也可以类内access section的顺序。
下面:
class Point3d {
public:
virtual ~Point3d();
// ...
protected:
static Point3d origin;
float x, y, z;
};
如何获得vptr
的位置呢?语句& 3d_point::z;
只能获得z
在类内的offset。如果地址是4个字节而vptr放在前面的话,这个offset应该是12,vptr放在后面应该是8;但是实际的值却是要加一。为什么呢?
问题在于如何区分不指向数据成员的指针和指向第一个数据成员的指针。考虑以下例子:
float Point3d::*p1 = 0;
float Point3d::*p2 = &Point3d::x;
// oops: how to distinguish?
if ( p1 == p2 ) {
cout << " p1 & p2 contain the same value — ";
cout << " they must address the same member!" << endl;
}
所以知道了下面两个的区别
& 3d_point::z;
& origin.z;
并且返回类型是float*
而不是float Point3d::*
多继承就更加复杂了,比如:
struct Base1 { int val1; };
struct Base2 { int val2; };
struct Derived : Base1, Base2 { ... };
void func1( int d::*dmp, d *pd )
{
// expects a derived pointer to member
// what if we pass it a base pointer?
pd->*dmp;
}
void func2( d *pd )
{
// assigns bmp 1
int b2::*bmp = &b2::val2;
// oops: bmp == 1,
// but in Derived, val2 == 5
func1( bmp, pd )
}
会被转换为下面(保证bmp不是0)
// internal transformation
// guarding against bmp == 0
func1( bmp ? bmp + sizeof( Base1 ) : 0, pd );
----Efficiency of Pointers to Members