Xander's Wiki


  • Home

  • Tags

  • Categories

  • Archives

  • Search

Data语义学——Data Member的存取

Posted on 2018-05-12 | In Inside the C++ object model

前言

1
2
Point3d origin;
origin.x = 0.0;

可能会有人好奇x的存取成本有多大,答案是视Point3d与x如何声明而定,x可能是一个static member或者nonstatic。Point3d可能是一个独立的class,也可能从另一个单一的base class派生而来,甚至可能是从多重继承或虚拟继承而来,下面将依次介绍这几种情况。
在探究之前,我们再次抛出一个问题:从object本身存取data member与从指向object的指针存取data member有何差异:

1
2
Point3d origin,*pt = &origin;
origin.x = 0.0;pt->x = 0.0;

在本节结束前我们将回答这个问题。


static data members

 
Static data members被编译器提出于class之外,并被视为一个global变量(只在class生命范围之内可见)。每一个member的存取access level,以及与class的关联性,并不会导致任何空间或时间的额外负担。

指针存取与对象存取

每一个static data member仅具备一个实体,存放于程序的data segment之中,每次程序存取static member都会被编译器视为该extern实体的存取操作:

1
2
origin.chunkSize=250;//Point3d::chunkSize=250;
pt->chunkSize=250;//Point3d::chunkSize=250;

这是C++语言中“通过一个指针与直接操作对象存取member,效果完全一样”的唯一情况。造成这种情况的原因在于.操作符根本并没有进入object内部获取数据。如果该static data member从复杂继承关系中继承而来,并不影响这个结论的正确性,因为static data member仅仅具备一个实体,其存取路径不受任何继承关系影响。

函数调用存取

如果static data member的存取经由函数调用而被存取,如果我们写:

1
foobar().chunkize=250;

调用foobar()时会发生什么?(foobar()的结果并不能改变static member,作者的意思在于查看编译器在此处是否存在优化)
在C++ pre-Standard规格中,ARM(Annotated Reference Manual)并未指定foobar()是否必须被evaluated。但C++ Standard明确要求foobar()必须被evaluated,虽然其结果并无用处:
1
2
(void) foobar();//evaluate expression,discarding result
Point3d.chunkSize()=250;

名变换

若取一个static data member的地址,会得到一个指向其数据类型的指针,而不是一个指向其class member的指针,因为static member并不内含在一个class object之中,这一点十分显然。
但如果有两个classes,每一个都声明了一个static member freeList,那么当它们都置于程序的data segment内部时会出现名称冲突。编译器的解决方法是暗中对每一个static data member编码(name-mangling,名变换)。名变换具备两个特点:

  1. 每一种算法对应一种独一无二的名字
  2. 编译系统如果需要与开发者进行交互,这些名称可以被轻易推导回原有名称。

Nonstatic Data Members

 

Nonstatic Data Members与object

Nonstatic Data Members存在于class object内部,除非经由explicit/implicit class object,没有办法直接存取它们。只要我们试图在某个member function中直接处理一个nonstatic data member,那么implicit class object就一定会发生:

1
2
3
4
5
void Point3d::translate(const Point3d &pt){
x+=pt.x;
y+=pt.y;
z+=pt.z;
}

实质上这一系列操作都是通过this指针完成,其函数的真正表示形式大致如下:
1
2
3
4
5
void Point3d::translate(point3d *const this,const Point3d &pt){
this->x+=pt.x;
this->y+=pt.y;
this->z+=pt.z;
}

nonstatic data members的存取

如果需要对一个nonstatic data member进行存取操作,那么编译器需要获取当前object的地址,然后通过偏移量计算出data member的地址,举例而言:

1
&origin+(&Point3d::_y-1);

-1操作是其中的重点。指向data member的指针,其offset的值总是被加上1,这样就可以让编译器区分出“一个指向data member的指针,用以指出class的第一个member”,以及“一个指向class object的指针”两种情况。

nonstatic data member与继承

每一个nonstatic data member的offset在编译期间便已得知,甚至如果member属于一个base class subobject也是一样,因此,存取一个nonstatic data member的效率等价于存取一个C struct member或一个nonderived class的member。

虚继承

虚继承为数据的存取导入了一层间接性,例如:

1
2
Point3d *pt3d;
pt3d->_x = 0.0;

如果_x是一个virtual base class的member,存取速度相较于其他情况要慢一点,其具体原因可见后续章节(继承对于对象布局的影响)。

我们回到本节一开始时提出的问题:通过对象存取与通过指针存取有多大的差别?

1
2
origin.x = 0.0;
pt->x = 0.0;

如果继承体系中存在一个virtual base class,且被存取的member是一个从virtual base class继承得到的member时,上述两种写法存在较大差异。因为在此时我们无法确定pt必然指向哪一种class type(因此我们也就不知道编译期这个member真正的offset),因此该存取操作必然需要被延后到执行期,经由一个额外的间接引导。但如果使用对象则不存在这种问题,因为静态类型早已绑定,其offset也早已在编译期确定。一个优秀的编译器甚至能够静态地经由origin解决对x的存取。

Data语义学——Data Member的布局

Posted on 2018-05-12 | In Inside the C++ object model

已知下面一组data member:

1
2
3
4
5
6
7
8
9
10
class Point3d{
public:
...
private:
float x;
static List<Point3d*> *freeList;
float y;
static const int chunkSize = 250;
float z;
}

Nonstatic data members在class object中的排列顺序将与其被声明的顺序一致,任何中间介入的static data member不计入对象布局,它们被置于程序的data segment之中,与class object无关。

C++ Standard约定,在同一个access section(例如private,public,protected),members的排列只需符合“较晚出现的members在object中具备较高的地址”这一条件。也就是说,members可能在内存中并不是连续排列的,alignment造就了这种情况(填补了一些bytes)。

编译器还可能会自己生成一些data members以完成特定的功能,比如说vptr,其具体位置视编译器而定,可能在user members之前,之后,之间,只要保证满足上一段所说的要求即可。

可能一个class会由于写作原因出现多个access sections:

1
2
3
4
5
6
7
8
class Point3d{
private:
int x;
public:
int y;
private:
int x;
};

该class生成的object大小与组成均与单access section时相同,access section的多寡并不会造成额外负担。

Data语义学——Data Member的绑定

Posted on 2018-05-12 | In Inside the C++ object model

考虑以下程序:

1
2
3
4
5
6
7
8
9
10
extern float x;//外部的x变量
class Point3d{
public:
Point3d(float,float,float);
float X() const {return x;}
void X(float new_x) const {x=new_x;}
...
private:
float x,y,z;
};

如果我们调用Point3d::X(),那么它返回的是Point3d::x还是extern x?熟悉C++的人都明白是前者,但并非一直以来都是前者。

在C++最早的编译器上,如果在Point3d::X()的两个函数实例中对x进行参阅(取用)操作,这操作将会指向global x object。这种结果基本上不在开发者的预期之内,因此直接导致了C++早期的两种防御性程序设计风格:

  1. 将所有的data members放在class声明起头处,以确保正确的绑定:
    1
    2
    3
    4
    5
    class Point3d{
    float x,y,z;
    public:
    ...
    }
  2. 将所有的inline functions无论大小都置于声明之外
    1
    2
    3
    4
    5
    6
    7
    8
    9
    class Point3d{
    public:
    Point3d(float,float,float);
    float X() const;
    void X(float) const;
    };
    inline float Point3d::X() const {
    return x;
    }

这些程序设计风格直至今日仍然存在,虽然已经不再具备必要性。这个古老的语言规则被称为“member scope resolution rules”,大意是“一个inline函数实体,在整个class声明未被完全看见之前,是不会被evaluated的”。C++ Standard以“member scope resolution rules”来精炼这个“rewriting rule”,其效果是,如果一个inline函数在class声明后立刻被定义的话,那么还是对其evaluate,也就是说,

1
2
3
4
5
6
7
8
9
10
extern float x;
class Point3d{
public:
//对函数的分析将延迟至class声明的右大括号出现才开始
float X() const {return x;}
...
private:
float x,y,z;
};
//分析在这里进行

因此,在一个inline member function躯体内的一个data member绑定操作,会在整个class声明完成后才完成。

然而,这对于member function的argument list并不为真。Argument list中的名称还是会在它们第一次遭遇时被适当地resloved。因此在extern与nested type names之间的非直觉操作绑定操作还是会发生。例如,在下面的程序段中,length的类型在两个member function signatures中都被reslove为global typedef,也就是int。当后续再有length的nested typedef声明出现时,C++
Standard就把之前的绑定标示为非法:

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef int length;
class Point3d{
public:
//length被resloved为global
//_val被resloved为Point3d::_val
void mumble(length val) {_val = val;}
length mumble() {return _val;}
private:
//length必须在“本class对它的第一个参考操作”之前被看见
//该声明将使之前的参考操作不合法
typedef float length;
length _val;
};

上述这种语言状况,仍然需要某种防御性编程风格:请始终将nested type声明放在class的起始处。在上述例子中,如果把length的nested type定义于“在class中被参考”之前,就可以确保非直觉绑定的正确性。

Data语义学——前言

Posted on 2018-05-11 | In Inside the C++ object model

假设现有多重继承体系如下:

1
2
3
4
class X {};
class Y:public virtual X {};
class Z:public virtual X {};
class A:public Y,public Z {};

上述X、Y、Z、A中没有任何一个class内含明显的数据,它们之间只存在继承关系,因此理论上每一个class的大小都应该为0,但事实上,它们的大小均不为0:
1
2
3
4
sizeof(X) == 1;
sizeof(Y) == 4;
sizeof(Z) == 4;
sizeof(A) == 8;

一个空的class如:

1
class X {};

事实上并非是空的,它有一个隐晦的1byte,那是被编译器安插进去的一个char(Effective C++ 40)。因此这个class具现化得到的object能够在内存中配置独一无二的地址:
1
2
X a,b;
if(&a == &b)...

那为什么Y和Z的大小会是4呢,这与三个因素有关:

  1. 语言本身的额外负担
    当语言支持virtual base classes时,就会导致一些额外负担。在derived class中,这个额外负担反应在某种形式的指针身上,它或者指向virtual base class subobject,或者指向一个相关表格:表格中存放的是virtual base class subobject的地址或其偏移量。
  2. 编译器对于特殊情况所做的优化处理
    Virtual base class X subobject的1 bytes大小也出现在class Y和Z上。传统上它被放在derived class的固定(不发生变动)部分的末端。有些编译器会对empty virtual base class提供特殊支持,但有的没有。
  3. Alignment的限制
    在大部分机器上,群聚的结构体大小会受到alignment的限制,使它们能够更有效的在内存中被存取。

每一个class object都必须有足够大小来容纳其所有nonstatic data members,有时候它们的大小可能会和我们预想的要大一些,原因在于:

  1. 由编译器自动加上的额外data members,用以支持某些语言特性(主要是virtual特性)。
  2. 因为alignment的需要。

构造函数语义学——Member Initialization List

Posted on 2018-05-10 | In Inside the C++ object model

(5.4-5.9由于开题事宜断更,即日起恢复更新)

前言

 
当写下一个constructor时我们可以设定class members的初值:它们要么经由Member Initialization List,要么在construtor内部处理(除了4种情况必须使用member initialization list)。


何时必须使用member initialization list

 
以下四种情况必须使用member initialization list:

  1. 初始化一个reference member
  2. 初始化一个const member
  3. 调用一个base class的constructor,而它拥有一组参数
  4. 当调用一个member class的constructor,而它拥有一组参数

member initialization list详解

不使用member initialization list

考虑如下程序:

1
2
3
4
5
6
7
8
9
class Word{
String _name;
int _cnt;
public:
Word(){
_name=0;
_cnt=0;
}
};

该程序能够正确编译并执行,只是效率偏低。编译器在执行上述程序扩张代码使之生成一个临时对象,如下所示:
1
2
3
4
5
6
7
Word::Word(/*this poniter*/){
_name.String::String();
String temp = String(nullptr);
_name.String::operator=(temp);
temp.String::~String();
_cnt=0;
}

为了保证高效,我们应当做到对任何初始化操作都执行member initialization list。

member initialization list的作用

当构造函数中出现member initialization list后,编译器会一一操作member initialization list,以member声明次序(而非list内部次序)在constructor之内安插初始化操作,次序问题极易引发危险操作,例如:

1
2
3
4
5
6
class X{
int i;
int j;
public:
X(int val):j(val),i(j) {}
};

将会被扩张为:
1
2
3
4
X::X(int val){
i=j;//初始化失败
j=bal;
}


member initialization list的疑难点

二探次序

如果member initialization list中的项目被安插到constructor中,会继续保存声明次序吗?

1
2
3
4
X::X(int val)
:j(val){
i=j;
}

j的初始化操作会安插在explicit user assignment之前或者是之后?答案是initialization list的项目总会被放在explicit user code之前。

initialization list与member function

能否调用一个member function以设定一个member的初值?

1
2
3
X::X(int val)
:i(xfoo(val)),j(val)//xfoo是X的一个member function
{}

答案是可行的。但是…务必使用“存在于constructor体内的一个member”,而非“存在于member initialization list中的member”,来为另一个member设定初值。因为我们无法了解xfoo()对object的依赖性,在确保了xfoo()在constructor内部之后,对于“究竟是哪一个member在xfoo()执行时被设立初值”,就不会造成歧义。

member function的使用是合法的,是建立在和此object相关的this指针已经就位(我们暂且忽略与该member function相关的member),此时原代码大致被扩张为:

1
2
3
4
X::X(/*this pointer*/){
i = this->xfoo(val);
j = val;
}

如果一个derived class member function被调用,其返回值作为base class constructor的参数呢?
1
2
3
4
5
6
7
class FooBar:public X{
int _fval;
public:
int fval() {return _fval;}
FooBar(int val):_fval(val),X(fval()) {}
...
}

其扩张结果如下:
1
2
3
4
FooBar::FooBar(/*this pointer*/){
X::X(this,this->fval());
_fval =val;
}

显然,这确实不合时宜。


总结

 
简单地说,编译器会对initialization list一一处理并且重新排序,以反映出member的声明次序。它会安插部分代码到constructor体内,并置于任何explicit user code之前。

构造函数语义学——程序转化语义学

Posted on 2018-05-03 | In Inside the C++ object model

前言

 
考虑如下程序:

1
2
3
4
5
6
#include "X.h"
X foo(){
X xx;
...
return xx;
}

我们可能会做出如下假设:

  1. foo()一旦被调用,就会传回xx的值。
  2. 如果class X定义了一个copy constructor,当foo()被调用时,该copy constructor一定会被调用。

第一个假设的正确性视class X的定义而定。第二个假设的正确性,则视编译器的优化程度而定。我们甚至可以假设,在一个高品质C++编译器中,上述两点假设均不正确。


Explicit Initialization

 
已知存在如下定义:

1
X x0;

下面有三个定义,每一个都显式地以x0的值来初始化class object:
1
2
3
4
5
void foo_var(){
X x1(x0);
X x2 = x0;
X x3 = X(x0);
}

必要的程序转化有两个阶段:

  1. 重写每一个定义,剥离其中的初始化操作
  2. class的copy constructor调用操作被插入程序

经过转化后的foo_bar()程序看起来如下所示:

1
2
3
4
5
6
7
8
9
10
void foo_bar(){
//剥离初始化操作
X x1;
X x2;
X x3;
//插入copy constructor调用操作
x1.X::X(x0);//调用 X::X(const X& xx)
x2.X::X(x0);
x3.X::X(x0);
}


Argument Initialization

 
C++ standard中提及,将一个class object作为函数参数传递(或作为函数返回值)等价于以下形式的初始化:

1
X xx = arg;

因此,若有函数及调用语句如下:
1
2
3
4
void foo(X x0);
X xx;
//...
foo(xx);

将会要求local instance x0以memberwise的方式将xx作为初值。
在编译器实现技术上,有一种策略是导入临时对象,并调用copy constructor将它初始化,然后将临时对象转交函数:
1
2
3
X _temp0;
_temp0.X::X(xx);
foo(_temp0);//改写调用

然而这样的转换并不完备,问题出在foo的声明式中。临时对象已经得到了正确的初值,但传入函数作为参数时又再一次导致了临时对象的生成。为了保证函数被正确调用,编译器会修改foo的声明,其形参会由一个class object变为相应的reference:
1
void foo(X& x0);

当foo完成后,临时对象将调用X::~X(),确保自身被析构。

另有一种编译器实现策略:copy construct,它将实际参数直接建构在其应该的位置上,在函数返回之前,局部对象(不同于刚才的临时对象)会被析构(我认为此法就是真正的建立一个object,然后在使用完毕后析构之)。


Return Value Initialization

 
考虑如下函数定义:

1
2
3
4
5
X bar(){
X xx;
//处理xx
return xx;
}

我们需要关注的问题是bar()的返回值将如何从局部对象xx中拷贝。一种解决方案是双阶段转化:

  1. 首先加上一个额外参数,其类型是class object的一个reference,该参数用来放置被copy construct得到的返回值。
  2. 在return指令前插入一个copy constructor操作,将局部对象的copy作为引用初值传递回去。

转换后的bar函数大致如下:

1
2
3
4
5
6
7
void bar(X& _result){
X xx;
xx.X::X();
//处理xx
_result.X::X(xx);
return;
}

同时现在的bar调用操作也被转换,以确保和新的定义匹配:
1
2
3
4
5
//开发者撰写
X xx = bar();
//转换后
X xx;
bar(xx);

对于返回值的相关使用也被改写:
1
2
3
4
5
//开发者撰写
bar().memfunc();//调用成员函数
//转换后
X _temp0;
(bar(_temp0),_temp0).memfunc();//,表达式

同样的,如果程序声明了一个函数指针:
1
2
3
4
5
6
//开发者撰写
X (*pf)();
pf = bar;
//转换后
void (*pf)(X&);
rx = bar;


Optimization at the User Level

 
这种做法就是More Effective C++提及的RVO:

1
2
3
X bar(const T &y,const T &z){
return X(y,z);
}

如果编译器对RVO作出改写,那么应该有形式如下:
1
2
3
4
5
X _result;
void bar(X &_result,const T &y,const T&z){
_result.X::X(y,z);
return ;
}

仅仅付出了一个构造函数的代价,而这正是我们想要的。


Optimization at the Compiler Level

 
在一个诸如bar()的函数中,所有的return指令返回相同的named value,因此编译器可能会自己作出优化:方法是以result参数取代named return value:

1
2
3
4
5
6
7
8
9
10
11
X bar(){
X xx;
...//处理xx
return xx;
}
void bar(X &_result){
//调用default constructor
_result.X::X();
...//处理res
return ;
}

(此种策略就是把xx转换为了_result)
这种编译器优化操作被称为Named Return Value(NRV)。

NRV受到的批判

尽管NRV优化提供了重大的效率改善,但它仍然饱受批判,其原因大致有三:

  1. 优化由编译器自主执行,其执行程度不可控(很少有编译器会说明其实现程度或是否实现)。
  2. 如果函数相当复杂,优化就变得难以执行,许多人认为这种情况应当使用RVO取代之。
  3. 优化可能破坏了原有的程序

一般而言,当一个class object作为另一个class object的初值这种情况发生时,C++允许编译器具有较大的弹性发挥空间。其优点在于机器码产生时效率得到了明显的提高,但缺点就是你无法清楚你的代码究竟被编译成为了何种形式。


是否需要定义copy constructor

 

1
2
3
4
5
6
7
class Point3d{
public:
Point3d(float x,float y,float z);
//...
private:
float _x,_y,_z;
};

上述class有必要定义copy constructor吗?如果不定义,那么编译器会将default copy constructor视作trivial,其memberwise初始化操作会导致bitwise copy,这无疑兼具高效性与安全性。

那么上述class就一定没有必要定义copy constructor吗?答案是否定的。如果class object多次以传值的姿态作为函数参数,又或者作为函数返回值,那么explicit copy constructor有助于编译器执行NRV优化,当然我们也可以显式地使用RVO优化。


总结

 
copy constructor的使用多多少少使得编译器对你的代码做出了转化,如果我们能够了解那些转换,以及copy constructor优化后的可能状态,就可以对程序的执行效率有清楚的认知与控制。

构造函数语义学——Copy Constructor的建构操作

Posted on 2018-05-03 | In Inside the C++ object model

前言

 
在三种情况下,会以一个object的内容作为另一个class object的初值:

  1. object明确初始化

    1
    2
    3
    class X{...};
    X x;
    X xx = x;
  2. object被当作参数交与某个函数

    1
    2
    3
    4
    5
    extern void foo(X x);
    void bar(){
    X xx;
    foo(xx);//作为第一个参数的初值(不明显的初始化)
    }
  3. 函数返回值是一个class object

    1
    2
    3
    4
    5
    X foo_bar(){
    X xx;
    ...
    return xx;
    }

如果开发者已经明确定义了一个copy constructor如下:

1
2
3
//copy constructor可以是多参数,其中有一个参数是其class type
X::X(const X& x);
Y::Y(const Y& y);

那么在大部分情况下,当class object以另一个同类实体作为初值时,上述constructor会被调用,这可能会导致一个暂时性class object的产生或程序代码发生改变(或二者都有)。


Default Memberwise Initialization

 
如果函数并没有提供一个explicit copy constructor,那么其拷贝同类型对象的操作由default memberwise initialization完成,其执行策略为:对每一个内建或派生的data member的值,从某一个object拷贝到另一个object。不过它不会拷贝其中的member class object,而是实施递归式的memberwise initialization(对每一个对象依次执行default memberwise initialization)。

实例

考虑如下class声明:

1
2
3
4
5
6
7
class String{
public:
...//不存在explicit copy constructor
private:
char *str;
int len;
};

String object的default memberwise initialization发生于这种情况:
1
2
String noun("book");
String verb = noun;

其完成方式类似于依次各别设定每一个member:
1
2
verb.str = noun.str;
verb.len = noun.len;

如果String是另一个class的member:
1
2
3
4
5
6
7
class Word{
public:
...//不存在explicit copy constructor
private:
int _occurs;
String _word;
};

那么一个Word object的default memberwise initialization会拷贝其内建的member _occurs,然后对String member执行递归式memberwise initialization。

如果开发者没有为class指定copy constructor,那么编译器也会生成implicit的声明与定义。类似于Default constructor,C++亦把copy constructor区分为trivial与non-trivial两种,只有non-trivial的实体才会被合成于程序之中。决定一个copy constructor是否为trivial的依据是class是否表现出所谓的“bitwise copy semantics”。


Bitwise Copy Semantics

1
2
3
4
5
6
7
#include "Word.h"

Word noun("book");

void foo(){
Word verb = noun;
}

很明显verb是根据noun来初始化,但在未了解Word class声明之前,我们无法预测该初始化操作的程序行为。如果开发者定义了copy constructor,那么编译器会调用它。如果没有,编译器会根据class 是否展现出bitwise copy semantics,来合成一个copy constructor。

举例而言,如果Word内部数据仅仅含有内置类型,那么编译器不会合成copy constructor,而是执行bitwise copy。但如果Word内含一个String object,而String class存在一个explicit copy constructor,那么编译器将不得不合成一个copy constructor,以调用member object的copy constructor,在该合成的copy constructor中,内置类型仍然使用bitwise copy。


Bitwise Copy不出现的情况

 
一个class有四种情况不会表现出bitwise copy:

  1. class内含一个member object,而后者有一个copy constructor(无论是开发者指定还是编译器合成)
  2. class继承自一个base class,而后者存在一个copy constructor(无论是开发者指定还是编译器合成)
  3. class声明了一个或多个virtual functions
  4. class存在的继承体系内存在一个或多个virtual base class

在前两种情况下,编译器会将member或base class的copy constructors插入至合成的copy constructors中。


重设定vptr

 
在上一节我们曾经阐述过,含有virtual functions的class会在编译期间构造函数会自发扩张:

  1. class会增加一个vtbl
  2. class object会增加一个vptr

显然,vptr是决定多态机制能否正确运行的关键,当编译器将vptr导入至class之中时,该class不再具备bitwise semantics。编译器需要合成一个copy constructor,将vptr合理初始化。

实例

现有继承体系及class声明如下:
image_1cchrq8qc1m3sqpgqhe15te9389.png-6.9kB

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class ZooAnimal{
public:
ZooAnimal();
virtual ~ZooAnimal();
virtual void animate();
virtual void draw();
private:
...//some data
};
class Bear : public ZooAnimal{
public:
Bear();
void animate();
void draw();
private:
...//some data
};

ZooAnimal object相互赋值或者Bear object相互赋值都可以通过bitwise copy semantics完成。在这种情况下,vptr保持bitwise copy是安全的。

当一个base class object以其derived class object内容作初始化操作时,其vptr也需要保证安全:

1
2
Bear B;
ZooAnimal Z = B;//sliced

显然,Z的vptr不应该指向Bear的vtbl,也就是说,Base class被合成出来的copy constructor会明确设定object的vptr指向Base Class的vtbl,而非从rhs处执行bitwise copy。


处理Virtual Base Class Subobject

 
一个class object如果以另一个object作为初值,而后者带有一个virtual base class subobject,那么bitwise semantics同样会失效。

在上一节中我们已经编译器需要保证在运行期明确virtual base class subobject的位置。Bitwise copy可能会破坏这个位置。

实例

现有继承体系及class声明如下:
image_1cchsh99imb2u671bk629s13ubm.png-13.9kB
···C++
class Raccoon:public virtual ZooAnimal{
public:
Raccoon();
Raccoon(int val);
…
private:
…//some data
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
在开发者撰写的constructor中,编译器会生成:
1. 调用ZooAnimal的default constructor代码
2. Raccoon的vptr初始化代码
3. 定位Raccoon中ZooAnimal subobject代码

编译器会将上述代码插入至两个constructor之内,并且放在开头位置。

当一个class object以其derived class object作为初值时,bitwise失效,因为此时编译器必须判断“后续当程序员试图存取其Base class subobject时程序能否正常运行。
考虑如下继承体系与声明:
![image_1cchtdv2o1tvlga1o4414a752613.png-20.9kB][3]
```C++
class RedPanda:public Raccoon{
public:
RedPanda();
RedPanda(int val);
private:
...
}

现有object分析图如下:
image_1cchtl7dq17ulenn30r19ej1tft20.png-130.2kB
在下述代码中,编译器无法判断bitwise copy是否有效,因为编译器无法了解Raccoon是否指向了一个真正的Raccoon对象:
1
2
Raccoon *ptr;
Raccoon little_critter = *ptr;//可能ptr指向了派生类


总结

 
我们是否可以认为,在bitwise copy完全合理的情况下,应当禁止调用copy constructor以优化程序?这个问题将会在后续章节中讨论。
本节我们讨论了class不再保持bitewise copy semantics的四种情况,在这四种情况下,如果未声明copy constructor,那么编译器为了保证初始化正确,将会合成一个copy constructor。

构造函数语义学——Default Constructor的构造操作

Posted on 2018-05-02 | In Inside the C++ object model

前言

 
《C++ Annotated Reference Manual》(ARM)中曾提及:“default constructor…在需要的时候被编译器产生。”关键在于何时是被需要的时刻?

1
2
3
4
5
6
7
8
class Foo{public: int val;Foo *pnext;};
void foo_bar(){
//需求bar's member均初始化为0
Foo bar;
if(bar.val || bar.pnext){
...//do sth
}
}

在上述代码中,正确的程序语意要求bar的member被初始化为0,那么它符合ARM所说的“在需要的时刻”吗?答案是否定的。ARM所说的需要是编译器需要,而非程序语意需要。

C++ standard已经修改了ARM中的说法,但是实际相差不大。C++ standard约定:如果没有任何user—declared constructor,那么编译器会暗中(implictitly)自动生成一个default constructor(事实上标准会对此默认构造函数的修饰词是trival…)。

一个non-trivial default constructor才是编译器所需要的,下面的四个小节将分别讨论nontrival default constructor的四种情况。


“带有Default Constructor”的Member Class Object

 
如果一个class没有任何constructor,但它内含一个member object且后者带有一个default constructor,那么该class的implict default constructor就是所谓的“non-trival”,编译器需要为此class合成出一个default constrcutor。不过该合成操作仅仅发生于constructor真正需要被调用时。

那么在C++各个不同的编译模块中,编译器如何避免生成多个default constructor?解决方法是把合成的default constructor、copy constructor、destructor、assignment copy constuctor均以inline形式完成。一个inline函数具备静态链接(static linkage),无法被档案以外者观察到。如果函数过于复杂,那则合成出explicit non-inline static实体。

实例

在以下程序片段中,编译器会为class Bar合成一个default constructor:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Foo{
public:
Foo();
Foo(int);
};
class Bar{
public:
Foo foo;
char* str;
};
void foo_bar(){
Bar bar;//此处bar被初始化,原因在于foo具备默认初始化
}

被合成的Bar default constructor内含必要的代码,能够调用class Foo的default constructor来处理member object Bar::foo,但它并不产生任何代码来初始化Bar::str。被合成的default constructor看起来可能像这样:
1
2
3
inline Bar::Bar(){
foo.Foo::Foo();
}

为了保证上述程序片段正常运行,str也需要被初始化。我们假定程序员经由下面的default constructor提供了str的初始化操作:
1
Bar::Bar() {str=nullptr;}

现在程序的要求已经得到了满足,但编译器还需要初始化memeber object foo。由于default constructor已经被明确定义,所以编译器不会生成第二个。那么编译器会如何执行?
编译器的执行策略是:“如果class A中含有一个或一个以上的member class objects,那么class A的每一个constructor必须调用每一个member classes的default constuctor”。编译器会在已经编写的constructors中插入必要的代码,保证在user_code执行之前,先调用必要的default constructor。仍以Bar为例,其具体的构造函数大致如下所示:
1
2
3
4
Bar::Bar(){
foo.Foo::Foo();
str=nullptr;
}

如果具备多个class member objects,那么它们的初始化顺序何如?编译器的解决方案是:以“member objects在class中的声明次序”来调用各个constructors。


“带有Default Constructor的Base Class”

 
如果一个没有任何constructors的class派生自一个“带有default constructor”的base class,那么这个derived class的deafualt constructor会被视为non-trivial,并且根据需要被合成出来。它会根据base class的声明顺序依次调用base class的default constructor。

但如果设计者提供了多个constructors,但没有default constructor呢?编译器会扩张现有的每一个constructors,将“用以调用所有必要之default constructor”的程序代码加入其中。编译器不会去合成一个新的constructor,因此用户已经编写了其他constructors。如果同时亦存在着“带有default constructors”的member class objects,那些default constructor也会被调用——在所有base class constructor都被调用之后。

(我的理解是,如果base class具备多个构造函数,但不具备无参构造函数,那么编译器会在每一个有参构造函数中增加代码使其具备无参构造性,也就是可以理解为缺什么就再调用什么的default constructor)


“带有一个Virtual Function”的class

 
另有两种需要合成default constructor的情况:

  1. class声明(或继承)一个virtual function。
  2. class所在的继承体系中存在一个或多个virtual base classes。

上述两种情况由于缺乏user声明的constructors,编译器会详细记录合成一个default constructor的必要信息。

实例

以如下程序段为例:
image_1ccfn2t8p1gst10ks1v6o7pb1cjo9.png-11.4kB

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Widget{
public:
virtual void flip() = 0;
...
}
void flip(const Widget& widget){
widget.flip();
}
void foo(){
Bell b;
Whistle w;
flip(b);
flip(w);
}

以下两个扩张操作会在编译期完成:

  1. vtbl被编译器产生,内放class的virtual functions地址。
  2. 在每一个class object中,编译器会放置一个vptr指向vtbl。

此外,widget.flip()的virtual invocation会被改写:

1
*widget.vptr[1](&widget);//&widget代表着this指针

为了保证该机制生效,编译器必须为每一个Widget(包括其派生类)object的vptr设定初值。对于class所定义的每一个constructor,编译器都会扩张它们,在其中放入vptr的初始化。对于内部未声明constructor的classes,编译器会为它们合成一个default constructor,以便正确地初始化每一个class object的vptr。


“带有一个Virtual Base class”的class

 
尽管virtual base class的实现在不同编译器间存在较大差异,但共同点在于必须使每一个virtual base class在其每一个derived class object中的位置在执行期准备妥当。

实例

以如下代码及继承体系为例:
image_1ccfo2g9e1n84rf81pjt17301c58m.png-7.9kB

1
2
3
4
5
6
7
8
9
10
11
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;};

void foo(const A* pa) {pa->i=1000;}//无法在编译期了解pa->X::i的位置

main(){
foo(new A);
foo(new C);
}

编译器无法固定住foo()之中“经由pa而存取的X::i”的实际偏移位置,因为pa的动态类型是不确定的。编译器的操作是改变执行存取操作的那些代码,从而令X::i可以延缓到运行期执行。

cfront实例

以cfront编译器为例,其做法是在derived class object的每一个virtual base classes中安插一个指针。所有经由reference或pointer来存取一个virtual base class的操作都由该指针完成,如此一来,foo被改写为:

1
2
3
void foo(const A* pa){
pa->_vbcx->i = 1000;
}

_vbcx是编译器所产生的指针,指向virtual base class X。
_vbcx是在class object建构期间被完成得。对于class所定义的每一个constructor,编译器会在其中扩展代码,保证允许在执行期确定virtual base class的地址。如果class没有声明任何constructors,编译器就会为其合成一个。


总结

 
有四种情况会导致编译器为没有声明construtor的classes合成default constructor,C++ standard将这些被合成物称为implicit non-trivial default constructors。被合成物满足的编译器的需要而非程序员的需要。在这四种情况之外且没有声明任何constructor的classes,拥有的是implicit trivial default constructor,实际上它们并不会被合成出来。

在合成的default constructor中,只有base subobject与member class objects会被初始化,所有其他的non-static data member均不会初始化,因为这些操作对编译器而言并非必须,因此需要程序员手动完成。

C++新手一般会有两个误解:

  1. 任何class如果没有声明default constructor就会被合成出一个。
  2. 编译器合成出来的default constructor会初始化所有class member。

以上两条都是错的。

关于对象——对象的差异

Posted on 2018-05-01 | In Inside the C++ object model

C++设计范式

 
C++程序设计模型支持三种programming paradigms:

  1. procedursal model
    过程式编程模型,想必各位已经很熟悉了。
  2. abstract data type model
    该模型所谓的“抽象”是和一组表达式(public接口)一起提供,而其运算定义符隐而未明:
    1
    2
    3
    4
    String girl = "Anna";
    String daughter;
    //String::operator=();
    daughter = girl;
  3. object-oriented model
    在此模型中有一些彼此相关的类型,通过一个abstract base class(提供通用接口)被封装起来。

多范式混用带来的危害

纯粹以某一种paradigm编程有助于保证整体行为的稳定性与正确性,如果混合使用了多种paradigm,可能会造成意料之外的后果。

sliced

假设当前有Library_material与其派生类book,继承体系如下:image_1cccoqo1e1le9ners7a1o6bvln9.png-8.4kB
考虑如下代码:

1
2
3
4
5
6
7
8
//ADT paradigm
Library_material thing1;
Book book;
thing1 = book;//本意是重绑定
thing1.checkin();//本意是调用虚函数
//OO paradigm
Library_material thing2 = &book;
thing2.checkin();//book::checkin();

多态性是OO paradigm的重要特性,ADT paradigm不具备此特性,如果在使用时混用二者,极易造成sliced并且产生意外后果。


OO paradigm 与 ADT paradigm

在OO中,程序员需要处理一个个未知实体,它的类型虽然有所界定,却具备无穷可能。实体的类型受限于继承体系,但继承体系的深度与广度没有限制。原则上,被指定的Object的真实类型在每一个特定动作执行前无法确认,只有通过pointer或reference才能够完成这一系列操作。但在ADT中,程序员处理的是一个拥有固定且单一形态的实体,其所有特性在编译时期就已被完全定义:

1
2
3
4
5
//无法确定px rx的动态类型
Library_material *px = sth;
Libraay_material &rx = *px;
//静态类型已知
Library_material dx = *px;

多态的实现要求此object必须经由pointer或reference存取,但pointer与reference的操作并不总是构成多态(例如其指向对象不在继承体系中)。

多态

C++以下列方法支持多态:

  1. 一组隐式转换操作,例如把一个derived class指针转为指向base class的指针。
  2. virtual function机制
  3. 经由dynamic_cast与typeid运算符

多态的主要用途是经由一个共同的接口来影响类型的封装,该接口通常被定义在一个abstract base class中,通过多态性,我们得以保证当类型发生修改时,程序代码无需改变。


object的大小

 
究竟一个object需要占用多少内存?一般而言,object的内存占用由三部分组成:

  1. 所有non-static data member所占大小
  2. 为了支持virtual而产生的额外开销(vptr)
  3. 齐位所带来的额外占用

指针类型差异

 

1
2
3
ZooAnimal *px;
int *pi;
Array<String> *pta;

这三种指针有什么不同吗?
以内存需求的角度而言,这三种指针并无不同,都需要足够的内存来放置一个机器地址。“指向不同类型的指针的差异”(ZooAnimal*、int*等等),既不在于指针表示法,也不在于内容(地址)不同,而在于其寻址得到的object类型不同。“指针类型”的主要功能是:教导编译器如何解释某个特定地址中的内存内容及其大小:

  1. 一个指向地址1000的int*,在地址空间上将涵盖1000~1003(32位及其上整数是4-bytes)
  2. 如果String基于传统实现,那么地址空间上将涵盖1000~1015(4+8+4)
    image_1cccrhqma1r9fh2q10iq1tv76ccm.png-38.9kB

那如果现存在一个地址为1000但类型为void*的指针呢?我们无法了解其指向的具体对象,也无法判断它具体如何覆盖地址空间。

所以说,cast只是一种编译器指令,它并不改变真正的地址,只是影响编译器如何去解读地址。


引入多态之后的指针

 
现有继承体系如下:
image_1cccs68dq1ipbudv235mn51blk13.png-7.3kB

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Bear::public ZooAnimal{
public:
Bear();
~Bear();
void rotate();//继承得到的virtual
virtual void dance();//特有
protected:
enum Dances {...};
Dances dances_known;
int cell_block;
};
Bear b("Yogi");
Bear *pb = &b;
Bear &rb = *pb;

b、pb、rb的内存需求何如?
pointer与refernce只需要一个字长的大小,Bear object 需要24 bytes(ZooAnimal的16 bytes + Bear自有的8 Bytes):image_1cccspl1t15h52ss1jintkinfe1g.png-118.1kB


pointer to base 与 pointer to derived

1
2
3
Bear b;
ZooAnimal *pz = &b;
Bear *pb =&b;

一个Bear*与一个ZooAnimal*有何区别?
它们都指向了b的第一个byte,其差别在于pz只涵盖了b中的ZooAnimal subobject部分,pb则是涵盖了整个Bear object。除了Base部分之外,你无法调用任何Bear的专属member,唯一例外是virtual机制。当我们写下:

1
pz->rotate();

pz的类型将在编译期检查以下两点:(显式接口)

  1. 该接口是否存在于base内
  2. 该接口的access level是否为public

在运行期,pz所指向的object决定了rotate的调用实体。类型信息的封装并不存在于pz中,而是存在于vbtl的首元素。

考虑如下情况:

1
2
3
Bear b;
ZooAnimal za =b;//sliced
za.rotate();//ZooAnimal::rotate()

为什么调用的并不是Bear的函数实体?此外,如果初始化函数复制了object,为什么vptr没有发生变化指向新的vbtl?
关于第二个问题,编译器给出的答案是:如果某个object含有一个或以上一个以上的vptrs,这些vptrs的内容不会在初始化或赋值时发生改变。
对于第一个问题,答案是:za并不也不可能是一个Bear,它只能是一个ZooAnimal。有这么一种似是而非的观念:OO程序设计并不支持对object的直接处理。例如在如下继承体系中:
image_1cccuduvklms16ro1dbk88bpl51t.png-10.7kB
1
2
3
4
5
ZooAnimal za;
ZooAnimal *pza;
Bear b;
Panda *pp = new Panda;
pza =&b;

其内存布局可能如下:
image_1cccukhnsius1so1q1e18281jmf2a.png-216.3kB
我们只需要认定,pointer与reference的类型决定了该段内存被如何解析。但如果你试图改变object za的大小,比如将整个Bear Object赋给它,那就直接引发了sliced,于是多态便不再呈现。


OO与OB

 
C++通过pointer与reference来实现多态,这种程序设计风格被称为面向对象。
C++也支持ADT程序风格,现如今它们被称为OB(object-based)。OB提供封装的非多态形式,也不支持类型扩充,其典型实例就是STL中的各类容器。OB比OO更具备空间紧凑性,且提供更快的速度,但其缺点在于缺乏设计弹性。
在OO与OB之间存在着取舍,在我们使用之前必须分析当前应用领域的实际需求。

关于对象——关键词所带来的差异

Posted on 2018-05-01 | In Inside the C++ object model

(本节内容重在阐述struct与class关键词的区别,以及一些旧式技巧)

前言

 
C++本可以更加简洁与优雅,如果不需要去努力维系与C的兼容性的话。


关键词的困扰

 
“何时应该用struct取代class?”
作者认为,在了解struct与class的区别之后,你可以把struct认为默认public的class,甚至你可以认为struct只是为了帮助C语言开发者尽快地适应C++。

<i class="fa fa-angle-left"></i>1…121314…27<i class="fa fa-angle-right"></i>

xander.liu

266 posts
11 categories
36 tags
RSS
GitHub E-Mail
© 2024 xander.liu
Powered by Hexo
|
Theme — NexT.Pisces v5.1.4