Xander's Wiki


  • Home

  • Tags

  • Categories

  • Archives

  • Search

站在对象模型的顶端——Template

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

前言

 
原本Template被视为对container的一项重要支持,但其现在已经成为了通用程序设计的基石。它也被用于属性混合或互斥机制的参数化技术中,此外,TMU的发现,使得程序效率得到了巨大的提升。

然而,template也是C++最为困难的主题。本节将重点描述template,以下是三个主要讨论方向:

  1. template声明
    可以理解为当你声明一个template class或template class member function时会发生什么。
  2. 如何具现出class object以及inline nonmember,以及member template function
    上述的这些每一个编译单位都将拥有一份实体
  3. 如何具现出nonmember以及static template class members
    上述的这些每一个可执行文件中仅会存在一个实体。

Template的具现行为

 
现有Template Point class如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<typename Type>
class Point{
public:
enum Status {unallocated,normalized};
Point(Type x=0.0,Type y=0.0,Type z=0.0);
~Point();
void* operator new(size_t);
void operator delete(void*,size_t);
//...
private:
static Point<Type> *freeList;
static int chunkSize;
Type _x,_y,_z;
};

当编译器看到template class声明时,不会执行任何操作。因此,上述的static data members并不可用。虽然enum Status的真正类型在所有Point instantiations中都一样,但它们只能通过template Point class的某个实体来存取或操作,因此我们可以这样写:
1
Point<float>::Status s;

而不能写为:
1
Point::Status s;

static data member亦是如此。

如果我们定义一个指针指向特定的实体:

1
Point<float> *ptr =nullptr;

也不会生成或执行任何操作。我们需要明确,一个指向class object的指针,本身并不是一个class object,编译器不需要知道任何与class相关的任何members数据或object布局数据,因此没有将某个template具现的必要。C++ standard禁止编译器对此情况作出具现化操作。
但如果是一个reference,那它真的会具现化出一个class,原因在于reference必须要绑定某个真正存在的对象。

假若我们明确定义表达式导致某个template class发生具现化:

1
const Point<float> origin;

在此情况下,float instantiation的真正对象布局被产生出来,回顾之前的声明我们可以得知,origin的配置空间需要足够容纳3个float成员。
然而,member functions(未使用过的)不应该被具现化,它们应当仅仅在被使用时才会发生具现化。之所以需要这个规则,主要出于两个原因:

  1. 时间与空间效率
    如果某个class存在大量的member functions,而真正具现化出的某些class只使用了其中的一部分,那么全部具现化会造成一种性能上的浪费。
  2. 尚未实现的机能
    template class中定义的所有member functions并不一定适用于所有的Type,如果只具现那些真正使用到的member functions,那么template就能支持那些原本可能会造成编译器错误的类型。

Template的错误报告

 

1
2
3
4
5
6
7
8
9
template <class T>
class Mumble{
public:
Mumble(T t=1024):tt(t){
if(tt!=t) throw ex;
}
private:
T tt;
};

这个Template的声明存在下述错误:

  1. t被初始化为整数常量1024,其可行程度视真实类型而定。
  2. !=运算符可能尚未被定义,需要视实际使用情况而定。

上述的两个潜在错误只能在具现操作完成后才能被发现,就目前的编译器而言,面对template声明只能执行有限的错误检查。Nonemember与member template functions在具现行为发生之前一样没有完全的类型检验,这直接导致了一些十分露骨的template错误声明居然能够通过编译,例如:

1
2
3
4
5
6
7
8
9
10
11
12
template <typename type>
class Foo{
public:
Foo();
type val();
void val(type v);
private:
type _val;
}
//以下内容不会触发报错
template <typename type>
double Foo<type>::bogus_member() {return this->dbx;}//即使Foo并没有member function与data


Template中的名称决议方式

 
在讲本小节之前,我们首先需要明确两种定义:

  1. scope of the template defintion
    定义出template的程序,有实例如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
     extern double foo(double);
    template<class type>
    class ScopeRules{
    public:
    void invariant(){
    _member = foo(_val);
    }
    type type_dependent(){
    return foo(_member);
    }
    //...
    private:
    int _val;
    type _member;
    };
  2. scope of the template instantiation
    具现出template的程序:
    1
    2
    3
    extern int foo(int);
    //...
    ScopeRule<int> sr0;

在ScopeRules template中有两个foo()调用操作,在”scope of template definition”中,只有一个foo()函数声明位于scope内,然而在”scope of template instantiation”中,两个foo()函数声明都位于scope之内,如果我们使用了一个函数调用操作:

1
sr0.invariant();//调用哪一个foo()?

本次调用操作存在两个函数可以调用,分别为:
1
2
3
4
//scope of the template declaration
extern double foo(double);
//scope of the template instantiation
extern int foo(int);

因为_val的类型是int,那么很多人会认为实际调用的是int,然而,调用的是double。

Template中,对于一个nonmember name的决议结果是根据这个name的使用与否与“用以具现出该template的参数类型”有关而决定的。如果其使用互不相关,那么就以“scope of the template declaration”来决定name,反之则使用“scope of the template instantiation”。

在刚才的实例中,foo()与用以具现ScopeRules的参数类型无关,因为_val是一个int,一个类型不会根据不同的具现化而发生改变的数据,因此,member的类型并不会影响哪一个foo()被选中,所以编译器会选择double(在该scope中,只有一个foo()候选者)。

我们再次给出一个实例,该实例内容为:

1
sr0.type_dependent();//调用哪一个foo()?

这一次由于参数类型相关,当前候选域内有2个foo()候选者,int类型与当前类型一致,因此得以被选中。

上述两个实例表明编译器必须保持两个scope contexts:

  1. “scope of the template declaration”,用以专注于一般的template class。
  2. “scope of the template instantiation”,用以专注于特定的实体。

Member Function的具现行为

 
template的具现化大致来说有两种策略:

  1. 编译时期策略,程序代码必须在program text file中备妥可用。
  2. 链接时期策略,通过某些工具导引编译器的具现化行为。

    接下来作者描述了如何具现化template function。

执行期语义学——临时性对象

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

临时对象的生成

 
如果我们有函数如下:

1
T operator+(const T&,const T&);

那么在执行该函数时,可能会生成一个临时对象以存储需要传回的对象。是否会产生临时对象由很多因素决定,其中我们需要的知识点是NRV。

由于市场竞争的关系,大多数编译器均采取了一系列优化策略,以至于如下表达式:

1
T c = a+b;

几乎不可能产生临时对象。但值得注意的是下一种情况——与表达式意义相当的assignment statement:
1
c = a+b;

它会直接导致临时对象的生成:
1
2
3
4
T temp;
temp.operator+(a,b);
c.operator=(temp);
temp.T::~T();

我们可以认为,初始化操作总比赋值操作效率更高。
此外,还有一种生成临时对象的情况:没有出现目标对象:
1
a+b;

这会产生一个临时对象,用以放置运算后的结果。


临时对象的生命期

 
在C++ standard之前,临时对象的生命期并没有明确指定,完全由各编译器生产厂商决定。而C++ standard定义如下:临时对象的被摧毁,应该是对full expression求值过程中的最后一个步骤,该表达式造成临时对象的产生。

什么是full expression?你可以认为是被涵括的表达式中最外围的那个:

1
((objA>1024) && (objB>1024))?objA+objB:foo(objA,objB);

比如该三目运算符表达式即为full expression,任何一个子算式产生的任何一个临时对象,都应当在完整表达式被求值完成后销毁。

此外,C++ standard又有两个特例:

  1. 凡含有表达式执行结果的临时对象,应该存留到object的初始化操作完成为止。
    这种做法提高了初始化效率,防止了另一个临时对象的生成。
  2. 如果一个临时性对象被绑定于一个reference,对象将会被残留,直到被初始化的reference声明结束,或临时对象的生命期结束。

执行期语义学——new与delete运算符

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

前言

 

new operator看起来似乎是一个单一运算,例如:

1
int *pi = new int(5);

但实际上它由以下两个步骤组成:

  1. 通过适当的new运算符函数实体,配置所有所需内存:
    1
    int *pi = _new(sizeof(int));
  2. 给配置得来的对象设立初值:
    *pi = 5;

当然,初始化操作应当在内存配置成功后才执行:

1
2
3
//重写声明
int *pi;
if(pi = _new(sizeof(int))) *pi=5;

delete运算符性质类似,只是在使用前会判断一下参数是否为nullptr,并且需要注意的是,在执行完delete后,p并不会被置为nullptr。

如果我们new或delete的是一个具备constructor或destructor的object,那么在执行new或delete时会触发对应的函数调用(其实一直说到这都是描述new operator与operator new)。


new运算符的小机巧

 
new操作符的实现一般均很直截了当,但有两个精巧之处值得斟酌:

1
2
3
4
5
6
7
8
9
10
extern void*
operator new(size_t size){
if(size==0) size = 1;
void *last_alloc;
while(!(last_alloc = malloc(size)){
if(!_new_handler) (*_new_handler)();
else return nullptr;
}
return last_alloc;
}

虽然new T[0]从语法层胶墩来说合法,但语言要求每一次对new的调用都必须传回一个独一无二的指针,这也就是每一次当size为0都将其置1的原因。此外,new允许开发者提供自己的_new_handler()函数,这也就是循环调用的原因。

实际上,new运算符总是以标准的C malloc()完成,虽然这并不是强制要求。类似地,delete也总是以标准的C free()完成:

1
2
3
4
extern void 
operator delete(void *ptr){
if(ptr) free((char*)ptr);
}


针对数组的new与delete语意

 
当我们写下

1
int* p_array = new int[5];

时,vec_new并不会真正被调用,因为其主要功能是把default constructor施行于class objects所组成的数组的每一个元素身上,倒是new运算符函数会被调用:
1
int* p_array = (int*)_new(5*sizeof(int));

对于strcut这种Plain OI’s Data,其执行类似于内置类型,因为其不具备constructor与destructor。new与delete运算符只需要获取或释放内存即可,vec_new()也不会被调用。

但如果class定义有一个default constructor,那么某些版本的vec_new()将会被调用,举例而言:

1
2
3
4
Point3d *p_array = new Point3d[10];
//编译结果
Point3d* p_array;
p_array = vec_new(0,sizeof(Point3d),10,&Point3d::Point3d(),&Point3d::~Point3d());

需要注意的是,只有已经构造完毕的object才可以经过destructor处理,在发生异常时,vec_new释放所有资源。

在很久之前,delete对象数组时必须要指定数组长度delete[10] p_array,当然现在不需要了,不过这种写法依然有效。有人会产生疑问,delete[]是如何了解当前所需要析构的元素数目?答案是vec_new所传回的内存区块内有一个word记录了数组长度。另外需要记住的是,在数组中使用多态性是非常不恰当的行为,我们在任何情况下都应当避免当一个base class 指针或引用指向derived class object组成的数组。


Placement Operator new的语意

 
new操作符可以重载,其最常见的一种形态我们称为placement operator new,它需要第二个参数,用来指定分配内存的位置:

1
2
3
void* operator new(size_t,void* p){
return p;
}

有人认为如果其作用只是传回第二参数,那它的实际意义何在?事实上,placement operator new还负责调用constructor自动实施于所指地址:
1
2
Point2w *ptw = (Point2w*) arena;
if(ptw!=0) ptw->Point2w::Point2w();

然而存在一些小问题:假若当前我们需要被构造的内存段已经存在了一个object,而这个object拥有一个destructor。在我们执行placement operator new时,该destructor并不会被调用,可能会有人想到使用delete从而触发destructor,然而这也释放掉了这一块内存,并不可,。较好的做法是直接在地址上执行destructor(在后来的C++ standrad中出现了placement perator delete,它可以析构且不释放内存)。

执行期语义学——对象的构造与析构

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

前言

 
一般而言,constructor与destructor的安插恰如其分:

1
2
3
4
5
6
{
Point point;
//point.Point::Point() 插入构造
...
//point.Point::~Point() 插入析构
}

如果一个区段(以{}括起来的区域)或函数中有一个以上的离开点,情况又稍微混乱一些。Destructor必须被放在每一个离开点(当时object还处于存活状态)之前,例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
Point point;
switch(int(point.x())){
case -1:
//插入destructor
return;
case 0:
//插入destructor
return;
default:
//插入destructor
return;
}
//destructor在这里行动
}

在上述实例中会生成几个destructor,甚至会在区段结束前生成一个destructor——即使程序本身根本不可能执行到那里。那么同样地,goto指令可能也需要多个destructor调用操作。
我们倾向于把object放置在使用它的程序区段附近,这样通常可以节约不必要的构造与析构操作。


全局对象

 
假设当前存在如下程序段:

1
2
3
4
5
6
7
8
Matrix identity;

main(){
//identity必须被初始化
Matrix m1 = identity;
...
return 0;
}

C++保证,一定会在main()函数中第一次使用identity之前,将identity构造完毕,并且在main()函数结束之前把identity析构。全局对象如果存在constructor与destructor的话,我们一般称其需要静态的初始化操作与内存释放操作。

C++程序中所有的global objects都被置于程序的data segment中。如果明确指定给global object一个值,那么object将以该值为初始值,否则object所配置到的内存内容为0。这里和C语言存在一定的分歧,C语言不会给任何全局变量设定为0的初值。
我们可以认为,C++将global object在编译时期置于data segment中且内容为0,其constructor一直到程序startup时才会实施。我们必须对一个放置于program data segment中的object的初始化表达式做evaluate,这也是为什么一个object需要静态初始化的原因。


munch策略

cfront有一个执行静态初始化(以及内存释放)的可移植方法munch。其策略如下:

  1. 为每一个需要静态初始化的档案产生一个_sti()函数(static initialization),内带必要的constrcutor调用操作或inline expansions。例如前文所说的identity对象会在matrix.c中产生下面的_sit()函数:
    1
    2
    3
     _sti_matrix_c_identity(){
    identity.Matrix::Matrix();
    }
  2. 类似地,在每一个需要静态的内存释放操作文件中产生一个_std()函数(static deallocation),该函数内带必要的destructor调用操作,或其inline expansions。
  3. 提供一组runtime library “munch”函数:一个_main()函数(用以调用所有可执行文件中的_sti()函数),以及一个_exit函数(用以调用所有的_std()函数)。

其执行策略如图所示:image_1celqealem9v1eb0hhr10ip1cft9.png-120.2kB
(接下来作者以较多篇幅描述了cfont如何在众多可执行文件中找出_sti函数与_std函数,其内容有兴趣的读者可自行参阅,以及静态初始化的一些局限性)


局部静态对象

 
假设当前有程序片段如下:

1
2
3
4
5
const Matrix& identity(){
static Matrix mat_identity;
//...
return mat_identity;
}

Local static class object有语意如下:

  1. 其constrcutor只能执行一次,尽管上述函数可能会被调用多次。
  2. 其destructor只能执行一次,尽管上述函数可能会被调用多次。

编译器曾经的策略是无条件地在startup时构造对象,然而这会导致所有的local static class objects初始化,即使它们所在的那个函数从未被使用过。因此,应当仅在identity()被调用时构造mat_identity才是最佳选择。

对于上述机制,cfront有实现策略如下:首先,导入一个临时bool量以保护mat_identity的初始化操作,当第一次处理identity()时,该bool量为false,于是调用constructor,随后将临时bool量置为true。同样地,destructor也使用类似的策略。


对象数组

 
假设当前有对象数组定义式如下:

1
Point knots[10];

如果Point既没有定义一个constructor也没有定义一个destructor,那么编译器行为十分简单:只需要分配足够多的内存以容纳10个Point object。

但如果Point定义了一个default destructor,那么这个destructor必须轮流施行于每一个元素之上,一般而言,这是经由一个或多个runtime library函数达成。我们暂且将该此类函数命名为vec_new(),其基本形式如下:

1
2
3
4
5
6
7
void* vec_new(
void* array,//数组起始地址
size_t elem_size,//object大小
int elem_count,
void (*constructor)(void*),
void (*destructor)(void*,char)//析构操作,在有异常抛出时释放资源
)

对于上述数组定义表达式,编译器真正处理操作如下:
1
vec_new(&knots,sizeof(Point),10,&Point::Point(),0);

类似地,对于destructor,也有vec_delete()函数如下:
1
2
3
4
5
6
void vec_delete(
void *array,
size_t elem_size,
int elem_count,
void (*destructor)(void*,char)
)

如果开发者提供了一个或多个明确初值给对象数组,例如:

1
Point knots[10]={Point(),Point(1.0,1.0,0.5),0.5};

那么编译器将会将其转为:
1
2
3
4
5
6
7
Point knots[10];
//明确初始化前三个
Point::Point(&knots[0]);
Point::Point(&knots[1],1.0,1.0,0.5);
Point::Point(&knots[2],0.5,0.0,0.0);

vec_new(&knots+3,sizeof(Point),7,&Point::Point,0);


Default Constructors和数组

 
(本小结似乎在描述语言陋规,没看明白)

执行期语义学——导读

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

问题实例

 
当前我们有一个简单的表达式:

1
if(yy==xx.getValue()) ...

其中xx与yy分别有定义为:
1
2
X xx;
Y yy;

而X与Y有定义如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class X{
public:
X();
~X();
operator Y() const;
X getValue();
...
}
class Y{
public:
Y();
~Y();
bool operator==(const Y&) const;
...
}


问题剖析

 
在得到了上述定义之后,我们对之前那个简单的表达式进行剖析。
首先,我们决定equality运算符所参考到的真正实体。在本次实例中,它被resloved为“被overloaded的Y成员实体”。下面是该表达式的第一次转换:

1
if(yy.operator==(xx.getValue()))

Y的equality运算符需要一个类型为Y的参数,然而getValue()传回的却是一个类型为X的object,因此,在实际执行时,上述表达式发生了隐式转换:
1
if(yy.operator==(xx.getValue().operator Y()))

上述扩张是编译器根据class的语意自动补全的操作,你可以明确地写出它们,这样编译速度可能会快上一丢丢。

实际上这并非上述表达式的完全形态,在实际使用中我们生成了相对应的临时对象,用来放置函数调用所传回的值,最终,实际表达式被扩张成为:

1
2
3
4
5
6
7
8
{
X temp1 = xx.getValue();
Y temp2 = temp1.operator Y();
int temp3 = yy.operator==(temp2);
if(temp3) ....
temp2.Y::~Y();
temp1.X::~X();
}

如此看来,这个表达式似乎并不简单。这也是C++的一大困难之处:无法从程序代码看出表达式的复杂程度。在本章,我们将升入探究执行期所发生的一些转换。

构造、析构、拷贝语义学——析构语意学

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

如果class没有定义destructor,那么只有在class内置的member object(或者class的base class)拥有destructor的情况下,编译器才会合成出一个。否则,destructor将会被视为不需要,自然也就不需要被合成(更不需要被调用)。

不定义destructor反而具备某种功效性,我们不应当认为某一个class具备constructor就也应当具备destructor,实际上,我们应当根据“需要”而非“感觉”来提供destructor。

为了决定class是否需要一个程序层面的destructor(或constructor),开发者必须明确一个class object的生命在哪里结束(或开始),需要何种操作才能保证对象的完整性,这也是constructor和destructor何时发生作用的关键。举例而言,

1
2
3
4
5
6
7
{
Point pt;
Point *p = new Point3d;
foo(&pt,p);
...
delete p;
}

我们看到,pt和p在作为foo()的参数之前,都必须先初始化为某些坐标值。这个时候需要有一个constructor,一般而言,class的使用者无法检验一个local变量或者heap变量是否已经被初始化。从这个角度来看,一个constructor的存在至少令开发者得以确认object必然已被初始化。

再来关注delete语句。当我们在delete掉p后,有任何程序上必须要处理的吗?答案是否定的,因为在本实例中我们无需归还任何资源,因此,我们无需一个destructor。

接着考虑Vertex class,它维护了一个由紧邻的“顶点”所形成的链表,并且当一个顶点的生命结束时,在链表上来回移动以完成删除操作,因此,Vertex destructor需要一个destructor。

当我们从Point3d和Vertex派生出Vertex3d时,如果我们不提供explicit destructor但仍然希望Vertex destructor被调用,因此,编译器必须合成一个Vertex3d destructor,其唯一任务就是调用Vertex destructor。如果我们提供一个Vertex3d destructor,编译器会扩展它,使他调用Vertex destructor。一个由开发者定义的destructor被扩展的方式类似于constructors,但是顺序相反:

  1. destructor函数本身首先被执行
  2. 如果class拥有member class objects,而后者拥有destructors,那么它们会以其声明顺序的相反顺序被调用。
  3. 如果class内带一个vptr,则现在将会被重新设定,指向适当的vtbl(当前object已经退化)。
  4. 如果上层nonvirtual base class拥有destructor,它们会以其声明顺序的相反顺序被调用。
  5. 如果任何virtual base class拥有destructor,而当前讨论的class是最尾端class(most derived),那么它们会以其原来的构造顺序的相反顺序被调用。

类似于constructor,对于destructor的最佳实现策略就是维护两份destructor实体:

  1. 一个complete object实体,总是设定好vptr,并且调用virtual base class destrcutors
  2. 一个base class subobject实体,除非在destructor函数中调用一个virtual function,否则它绝不会调用virtual base class destructors并设定vptr。

我们可以认为,每一次destructor的调用都使当前的object退化为另一个object,它们在destructor之外的任何地方都是完整的。

构造、析构、拷贝语义学——对象的功能

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

(本章对前文所述的各种操作进行了测试,给出了优化与未优化的区别)

构造、析构、拷贝语义学——对象复制语义学

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

前言

 
当我们设计了一个class,并以一个class object指定给另一个class object时,我们有三种选择:

  1. 不提供任何操作,执行默认行为
  2. 提供一个explicit copy assignment operator
  3. 明确拒绝复制操作

如果选择了第三点,那么执行策略是将copy assignment operator与copy constructor声明为private,并且不提供任何定义。声明为private避免了在member function或friends以外的任何地点执行copy操作,不提供定义则是确保当其在friends或member function中被调用时链接器会报错。


copy assigenment operator语义

 
我们仍以Point class作为实例:

1
2
3
4
5
6
7
class Point{
public:
Point(float x = 0.0,float y = 0.0);
//不存在任何virtual function
protected:
float _x,_y;
};

如果我们只需要完成基础data拷贝操作,那么默认拷贝行为似乎已经足够并且高效,我们没有理由去提供一个copy assignment operator。

但如果我们不对Point提供一个copy assignment operator,而是光靠memberwise copy,编译器会生成一个copy assignment operator实体吗?答案是否定的,正如copy constructor一样,由于编译器认为此class已经有了bitwise copy语义,所以implicit copy assignment operator被视为毫无用处,也不会被合成出来。

一个class对于默认的copy assignment operator,在以下情况中不会表现出bitwise copy语意:

  1. 当class内带有一个member object,而其class有一个copy assignment operator。
  2. 当一个class的base class有一个copy assignment operator。
  3. 当一个class声明了任何virtual functions(我们不一定能够准确拷贝到class object的vptr地址,因为它可能是一个derived class object)。
  4. 当class继承自一个virtual base class(无论其有没有copy assignment operator)。

C++ Standard上说copy assignment operator并不表示bitwise copy semantics是nontrivial。实际上,只有nontrivial instances才会被合成。于是,对于下面的语句:

1
2
3
Point a,b;
...
a=b;

赋值操作将由bitwise copy完成,期间并没有copy assignment operator被调用。但我们可能仍然需要一个copy constructor以方便完成NRV操作,这里需要注意的是,一个class需要copy constructor并不代表其也需要copy assignment operator。

虚拟继承下的copy assignment operator

在Point class中导入copy assignment operator:

1
2
3
4
5
6
inline Point&
Point::operator=(const Point &p){
_x = p._x;
_y = p._y;
return *this;
}

现对继承体系作出修改,有Point3d虚继承自Point:
1
2
3
4
5
6
class Point3d:virtual public Point{
public:
Point3d(float x=0.0,float y =0.0,float z=0.0);
protected:
float _z;
};

如果我们没有为Point3d定义一个copy assignment operator,编译器就必须合成一个(理由见前述的2与4),合成得到的东西可能看起来如下:
1
2
3
4
5
6
inline Point3d&
Point3d::operator=(Point3d* const this,const Point3d &P){
this->Point::operator=(p);
_z=p._z;
return *this;
}

copy assignment operator有一个nonorthogonal aspect(不理想、不严谨的情况),因为我们无法使用member initialization list(不能使用的原因是Point并没有copy constructor),我们必须写成以下两种形式,才能够调用base class的copy assignment operator:
1
2
Point::operator=(p3d);
(*(Point*)this)=p3d;

缺乏copy assignment list看起来无所谓,但实际上如果缺乏它,编译器将无法抑制上一层base class的copy operators被调用。

问题实例

下面的Vertex copy assignment operator(Vertex也虚继承自Point):

1
2
3
4
5
6
inline Vertex&
Vertex::operator=(const Vertex &v){
this->Point::operator=(v);
_next = v.next;
return *this;
}

现在让我们从Point3d和Vertex中派生出Vertex3d,以下是Vertex3d的copy assignment operator:
1
2
3
4
5
6
7
inline Vertex3d&
Vertex3d::operator=(const Vertex3d &v){
this->Point::operator=(v);
this->Point3d::operator=(v);
this->Vertex::operator=(v);
...
}

编译器将如何在Point3d与Vertex的copy assignment operators中抑制Point的copy assignment operators呢?
答案相当多,但均不够完备(详见书中介绍)。作者的建议是不允许任何一个virtual base class的拷贝操作,或者更进一步地,不在任何virtual base class中声明数据(类似于Java中的interface)。

构造、析构、拷贝语义学——继承体系下的对象构造

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

构造函数扩充

 
当我们写下这样的表达式:

1
T object;

显然,T类型的constructor会被调用,但我们不太了解constructor的调用还伴随着什么。Constructor可能带有大量的隐藏代码,因为编译器会扩充每一个constructor,扩充程序视当前class T的继承体系而定,一般而言编译器所做的扩充操作大致如下:

  1. 记录在member initialization list中的data members初始化操作会被放进constructor函数本身,并以member的声明顺序为执行次序。
  2. 如果有member并未出现在member initialization list中且它有一个default constructor,则该default constructor被调用。
  3. 如果class object有vptr,则vptr会被设定初值,指向适当的vtbl(这发生在所有member构造或赋值之前)。
  4. 所有上一层的base class constructors必须被调用,其调用顺序以继承规格中的声明顺序为准,而非依赖member initialization list:

    如果base class存在于member initialization list之中,那么任何明确指定的参数都应当被传入
    如果base class并未存在于member initialization list之中,但其存在一个default constructor,则调用之
    如果base class是多重继承下的后续base class,则this指针必须要发生调整

  5. 所有virtual base class constructors必须被调用,从左到右,从深到浅

    如果class位于member initialization list中,则任何明确指定的参数都应当被传入,如果不在list中,则应当调用其default constructor
    此外,class中的每一个virtual base class subobject的offset必须在执行期可被存取
    如果class obejct是最底层的class,其constructors可能会被调用,某些用以支持该行为的机制应当被放入


问题实例

 
我们再一次以Point举例,本次我们为其加入一个copy constructor、一个copy assignment operator、一个virtual destructor如下:

1
2
3
4
5
6
7
8
9
10
class Point{
public:
Point(float x=0.0,y=0.0);
Point(const Point&);
Point& operator=(const Point&);
virtual ~Point();
virtual float z() {return 0.0;}
protected:
float _x,_y;
};

现有class Line,每一个Line可以由两个Point构造:
1
2
3
4
5
6
7
class Line{
Point _begin,_end;
public:
Line(float=0.0,float=0.0,float=0.0,float=0.0);
Line(const Point&,const Point&);
draw();
};

每一个explicit constructor都会被扩充以调用其两个member class objects的constructors。如果我们定义constructor如下:
1
2
Line::Line(const Point &begin,const Point &end):
_end(end),_begin(begin) {}//注意与声明顺序不一致

该constructor会被扩充为:
1
2
3
4
5
6
7
//C++伪代码
Line*
Line::Line(Line *this,const Point &begin,const Point &end){
this->_begin.Point::Point(begin);
this->_end.Point::Point(end);
return this;
}

由于Point声明了一个copy constructor、一个copy assignment operator,以及一个destructor,所以Line class的implicit copy constructor、copy operator与destructor均不为trivial。

当我们写下:

1
Line a;

implicit destructor将会被合成,在implicit destructor中,它的member class objects的destructors会被调用(以其构造的相反顺序):
1
2
3
4
5
6
//C++伪代码
inline void
Line::~Line(Line *this){
this->_end.Point::~Point();
this->_begin.Point::~Point();
}

这里需要注意的是,尽管Point的destructor具备virtual属性,但由于在本次实例中不具备任何多态性,因此在inline函数中其会被resolved statically。

类似地,当写下Line b = a时,copy constructor会被合成;写下a = b时,copy assignment operator会被合成。此处还有一个知识点在于不可忘记在operator=中处理自我赋值的可能性。


虚拟继承

 
考虑如下虚拟继承:

1
2
3
4
5
6
7
8
9
10
11
12
class Point3d:public virtual Point{
public:
Point3d(float x=0.0,float y=0.0,float z=0.0)
:Point(x,y),_z(z) {}
Point3d(const Point3d& rhs)
:Point(rhs),_z(rhs._z) {}
~Point3d();
Point3d& operator=(const Point3d&);
virtual float z() {return _z;}
protected:
float _z;
};

在本例中,传统的constructor扩充现象并无作用,这是由于virtual base class具备共享性:
1
2
3
4
5
6
7
8
9
//不合法的扩充内容
Point3d*
Point3d::Point3d(Point3d *this,float x,float y,float z){
this->Point::Point(x,y);
this->_vptr_Point3d = _vbtl_Point3d;
this->_vptr_Point3d_Point = _vtbl_Point3d_Point;
this->_z = rhs._z;
return this;
}

上述扩充内容是错误的,其错误之处将会在下文中给予说明。

问题实例

试考虑以下三种类派生情况:

1
2
3
class Vertex:virtual public Point {...};
class Vertex3d:public Point3d,public Vertex {...}
class PVertex:public Vertex3d {...}

该继承体系结构如下图所示:
image_1cekr65vk111p1p2gp9ej4jure9.png-28.4kB

问题剖析

显然,Vertex的constructor必须调用Point的constructor,然而,当Point3d与Vertex同为Vertex3d的subobjects时,它们对Point constructor的调用必然不可发生,取而代之的是,作为最底层class,Vertex3d有责任初始化Point。而在更下层的继承体系中,Pvertex将负责完成Point subobject的构造。

传统的扩充策略无法区分当前是否需要初始化virtual base class,因此,它必须条件式地测试传入参数,然后再决定是否调用相关的virtual base class constructors。以下为Point3d的constructor扩充内容:

1
2
3
4
5
6
7
8
9
//C++伪代码
Point3d*
Point3d::Point3d(Point3d *this,bool _most_derived,float x,float y,float z){
if(_most_derived!=false) this->Point::Point(x,y);
this->_vptr_Point3d = _vbtl_Point3d;
this->_vptr_Point3d_Point = _vtbl_Point3d_Point;
this->_z = rhs._z;
return this;
}

在更深层的继承情况下,例如在Vertex3d中,当调用Point3d与Vertex的constructor时,总是会把_most_derived参数设为false,于是抑制了两个constructors中对Point constructor的调用操作:
1
2
3
4
5
6
7
8
9
10
Vertex3d*
Vertex3d::Vertex3d(Vertex3d *this,bool _most_derived,float x,float y,float z){
if(_most_derived!=false) this->Point::Point(x,y);
//在调用上一层constructor时总是将_most_derived设置为false
this->Point3d::Point3d(false,x,y,z);
this->Vertex::Vertex(false,x,y);
...//设定vptrs
...//出入user code
return this;
}

这种策略可以保证语义正确无误,举例而言,当我们定义:
1
Point3d origin;

Point3d constructor可以正确地调用其Point virtual base class subobject。而当我们定义:
1
Vertex3d cv;

时,Vertex3d constructor正确地调用Point constructor。Point3d和Vertex的constructors会做每一件该做的事情——除了调用Point。

virtual base class constructors的被调用存在明确定义:只有当一个完整的class object被定义出来(例如origin),它才会被调用,如果当前object只是某个完整object的subobject,它则不会被调用。以此作为依据我们可以撰写更加优化的编译器策略:将每一个constructor一分为二,一个针对完整的object,另一个针对subobject。它们的区别主要在于是否调用virtual base constructor以及是否设定vptr。


vptr初始化语义学

 
仍以上文提及的集成体系为例,当我们定义一个Pvertex object时,constructor的调用顺序为:

1
2
3
4
5
Point(x,y);
Point3d(x,y,z);
Vertex(x,y,z);
Vertex3d(x,y,z);
PVertex(x,y,z);

假设该继承体系中的每一个class都定义了一个virtual function size(),返回当前pointer或者reference所指向的class的大小。更进一步地,我们假定每一个constructors均内置了一个调用操作:
1
2
3
4
Point3d::Point3d(float x,float y,float z)
:_x(x),_y(y),_z(z){
if(spyOn) cerr << size() << endl;
}

那么当我们定义PVertex object时,前述的五个constructor将会如何?C++语言规则约定,在构造函数尚未执行完毕时,对象本身性质并不明确。这意味着当每一个Pvertex base class constructors被调用时,编译系统必须确保有适当的size()函数实体被调用。最根本的解决方案是:在执行constructor时,必须限制一组virtual function候选名单,也就是说,必须要有一个确定的vptr指向正确的vtbl。为了能够正确地调用当前对应的virtual function实体,编译系统只需要简单地控制vptr的初始化和设定操作即可。

vptr初始化应当如何被处理?改答案视vptr在constructor中“应该何时被初始化”而定。我们有三种选择:

  1. 在任何操作之前
  2. 在base class constructors调用操作之后,但在member initialization list或开发者提供的user code之前
  3. 在所有事情之后

正确的选择是2,我们之前也是那么做的。令每一个base class constructor设定其对象的vptr,使它指向相关的vtbl,构造中的对象就可以严格而正确地编程“构造过程中所幻化出来的每一个class”的对象。constructor执行算法通常如下:

  1. 在derived class constructor中,“所有virtual base classes”及“上一层base class”的constructor会被调用。
  2. 对象的vptr初始化,指向相关vtbl。
  3. 如果存在member initialization list,它们将会在vptr完成设定后展开,以免有一个virtual member function被调用(在Effective C++中我们了解到应当尽量避免在构造和析构函数中调用virtual function)。
  4. 执行user code。

问题实例

以PVertex constructor为例:

1
2
3
4
PVertex::PVertex(float x,float y,float z)
:_next(nullptr),Vertex3d(x,y,z),Point(x,y){
if(spyOn) cerr << size() << endl;
}

它很有可能被扩展为:
1
2
3
4
5
6
7
8
9
10
11
PVertex*
PVertex::PVertex(PVertex* this,bool _most_derived,float x,float y,float z){
if(_most_derived!=false) this->Point::Point(x,y);
//无条件地调用上一层base
this->Vertex3d::Vertex3d(x,y,z);
//设定vptr
this->_vptr_PVertex = _vtbl_PVertex;
this->_vptr_Point_PVertex = _vtbl_Point_PVertex;
if(spyOn) cerr << (*this->_vptr_PVertex[3].faddr)(this) << endl;
return this;
}

尽管上述做法完美解决了虚拟机制的问题,但在实际使用中这种策略并不完美。

下面是vptr必须被设定的两种情况:

  1. 当一个完整的对象被构造时,如果我们声明一个Point对象,Point constructor必须设定其vptr。
  2. 当一个subobject constructor调用了一个virtual function时。

如果我们声明了一个PVertex对象,然后由于我们对其base class constructors的最新定义,其vptr将不需要在每一个base class constructor中被设定。解决方法是将constructor分裂为一个完整的object与一个subobject实体,在subobject中,vptr的设定可以省略(就像上一小节末尾提及的那样)。

构造、析构、拷贝语义学——"无继承“情况下的对象构造

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

(由于学校琐事不断,本月更新过少,下个月需要参加电赛,可能会再次影响更新)

问题实例

 
考虑下述程序:

1
2
3
4
5
6
7
8
9
10
Point global;

Point foobar(){
Point local;
Point *heap = new Point;
*heap = local;
...//do sth
delete heap;
return local;
}

其中第一行、第五行、第六行体现出了完全不同的对象产生方式:global内存配置、local内存配置和heap内存配置。第七行将一个class object赋值给另一个,第九行,明确地delete heap object,第十行则设定返回值(此处再次发生了构造)。

一个object的生命是该object的一个执行期属性,local object的生命从第五行定义开始直至第十行为止。global object的生命与整个程序的生命相同,heap object的生命从它被new出来开始,直至被delete。


问题剖析

 
我们假定Point有声明如下,C++ standrad称这种声明为所谓的Plain OI’s Data形式:

1
2
3
typedef struct{
float x,y,z;
} Point;

如果以C++来编译这段代码,那么编译器理论上会为Point 声明一个trivial default constructor、一个trival destructor、一个trival copy constructor、以及一个trival copy assignment operator。但是实际上,编译器会分析该声明,并为其贴上Plain OI’s Data卷标。

当编译器遇到定义如下:

1
Point global;

观念上Point的trival constructor与destructor都会被产生与调用,constructor在程序起始处被调用而destructor在程序的exit()处被调用(exit()由编译器产生,置于main()函数结束之前)。然而,那些constructor与destructor要么是没定义,要么是没调用,程序的行为一如在C中的表现一样。

除了一处例外:在C中,global被视为一个“临时性定义”,因为它没有一个明确的初始化操作。一个临时性定义会在程序中发生多次,它们的实例会被链接器折叠,只留下一个单独的实体,被放在程序data segment中一个“特别保留给未初始化之global objects使用”的空间。

C++并不支持临时性的定义,这是因为class构造函数的隐含应用之故。虽然大家公认C++能够判定出目前对象是一个class object或是一个Plain OI’s Data,但似乎我们没有必要去分辨它们。因此,global在C++中被视为完全定义(阻止其他的定义)。C与C++的一大差异在于,BSS data segment在C++中不甚重要,C++中所有的全局对象都被当做初始化过的数据来对待。

foobar()中的第五行,有一个Point object local,同样既没有被构造也没有被析构,如果其在未经初始化的情况下就试图去使用,那么会引发bug。至于heap object在第六行中的初始化操作:

1
Point* heap = new Point;

会被转换为对new操作符的调用:
1
Point *heap = _new(sizeof(Point));

需要注意的是,并没有任何default constructor施行于new运算符所传回的Point object身上,第七行对此object有一个赋值操作,我们可能会认为object是一个Plain OI’s Data,所以赋值操作将只是简单地纯粹位搬移操作,第九行执行的delete将被转换为对delete运算符的调用:
1
_delete(heap);

而非触发Point的trivial destructor。最后,函数以pass by value的方式将local当做返回值使用,这在观念上会触发trivial copy constructor,但实际上return操作只是执行了一个简单的位拷贝操作,因为对象是一个Plain OI’s Data。


抽象数据类型

 
现在我们考虑Point加入了private数据之后的情况:

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

众所周知,封装并未给其带来空间上的额外开销,另外,我们并没有声明copy constructor或copy assignment operator,因为default bitwise已经足够,出于类似的原因,我们也不需要为其声明destructor。

对于一个global实体,现有default constructor作用于其上:

1
Point global;//执行Point::Point(0.0,0.0,0.0);

由于global被定义在全局范畴,因此其初始化操作将会延迟到程序startup时才开始。

explicit initialization list

如果需要对class中的所有成员均设定常量初值,那么给予一个explicit initialization list会比较高效。有实例如下:

1
2
3
4
5
6
7
void mumble(){
Point local1 = {1.0,1.0,1.0};
Point local2;
local2._x=1.0;
local2._y=1.0;
local2._z=1.0;
}

local1的初始化操作比local2的更加高效,因为当函数的activation record被放进程序堆栈时,上述initialization list中的常量就可以被放入local1内存中。

Explicit initialization list存在3项缺陷:

  1. 仅能作用于class member均为public的情况
  2. 只能指定常量,因为仅有常量能在编译时期就可以被evaluate
  3. 由于不存在自动施行,因此初始化行为的失败可能性会比较高

一般来说,explicit initialization list带来的优势不足以弥补其弊端,但对于全局对象而言,explicit initialization list的效率要高于inline constructor。

在编译器层面,会有一个特殊的机制来识别inline constructor,然后将其转变为explicit initialization list那种格式(如果可以转变的话),如果不能转变,那么将提供member-by-member的常量指定操作:

1
2
3
4
5
{
Point local;
//inline expansion
local._x=0.0;local._y=0.0;local._z=0.0;
}


为继承做准备

 
我们的第三个Point声明,将为“继承性质”以及某些操作的dynamic resolution做准备:

1
2
3
4
5
6
7
class Point{
public:
Point(float x=0.0,y=0.0):_x(x),_y(y) {}
virtual float z();
protected:
float _x,_y;
};

virtual function的引入使得每一个Point object拥有一个vptr,这可能会引起空间性能的降低。另外,virtual function的引入也将引发编译器对Point class产生膨胀作用:

  • 我们所定义的constructor被附加了一些代码,以便将vptr初始化。这些代码必须附加于任何base class constructors的调用之后,但必须在任何由开发者撰写的代码之前。
    1
    2
    3
    4
    5
    6
    7
    Point* Point(Point *this,float x,float y):_x(x),_y(y){
    //以下为附加部分
    this->_vptr_Point = _vtbl_Point;
    this->_x = x;
    this->-y = y;
    return this;
    }
  • 合成一个constructor与一个copy assignment operator,而且其操作不再为trivial(但implicit destructor依然是trivial)。bitwise操作可能会导致vptr非法:
    1
    2
    3
    4
    5
    6
     inline Point*
    Point::Point(Point *this,const Point &rhs){
    this->_vptr_Point = _vtbl_Point;
    //执行原计划的复制操作
    return this;
    }

编译器在优化状态下可能会把object的连续内容拷贝到另一个object上,而不是考虑memberwise的赋值操作。C++ standard要求编译器尽量延迟nontrivial members的实际合成操作,直到其真正使用。

我们再一次分析如下程序:

1
2
3
4
5
6
7
8
9
10
Point global;

Point foobar(){
Point local;
Point *heap = new Point;
*heap = local;
...//do sth
delete heap;
return local;
}

*heap=localmemberwise赋值操作,可能会触发copy assignment operator的合成,及其调用操作的一个inline expansion:以this代替heao而以rhs代替local。

传值返回的那一行出现了更大的冲击,由于copy constructor的出现,foobar很有可能被转化成了下面的形式:

1
2
3
4
5
6
7
8
Point foobar(Point &_result){
Point local;
local.Point::Point(0.0,0.0);
//heap与之前相同
_result.Point::Point(local);//应用copy constructor
local.Point::~Point();//destroy local
return;
}

如果支持NRV优化,那么这个函数将会进一步被转化为:
1
2
3
4
5
Point foobar(Point &_result){
_result.Point::Point(0.0,0.0);
//heap部分相同
return;
}

一般而言,如果你的程序中充斥着大量需要return value的函数(例如数值计算函数),那么提供一个copy constructor比较合理,因为这会触发NRV优化。

<i class="fa fa-angle-left"></i>1…101112…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