构造函数语意学 笔记(三)

时间:2023-01-02 11:18:37

今天是构造函数语意学这个章节的第三次笔记,说实话,学到了很多,也困惑很多。不过闲时还是感叹真乃神书也。

若存在错误 请指正 万分感谢

1.程序转化语意学(Program Transformation Semantics)

  引例:

#include <iostream>
using namespace std;
//加载头文件
#include "X.h"
X foo(){
X x_1;
//对对象x_1进行处理的相关操作。
return x_1;
}

  两种正常假设:

   1.每调用一次foo()函数,会返回一个对象x_1的值。

   2.应该会调用类中的拷贝构造函数。

    两个假设的正确性需要参看类X中的定义。

2. 显式的初始化操作(Explicit Initialization)

如下定义: X x0;//定义一个对象x0;

  示例:

void foo_bar(){
X x1(x0);
X x2 = x0;
X x3 = X(x0);
}
//上面三种初始化操作显式的用x0初始化三个对象。
//但是在实际的编译器中可能会发生如下的转换。
//1.重写定义,其中的初始化操作会被剥离 。
//2.调用相关的拷贝构造函数。
//C++伪码:

void foo_bar(){
X x1;
X x2;
X x3;
x1.X::X(x0); //调用拷贝构造函数。
x2.X::X(x0);
x3.X::X(x0);
//可能在类X中会有类似的声明:
//X::X(const X&);
}
    可以看到,其实编译器背着我做了转化操作。可能我们理解的简单操作,在编译器内部实现却是另一番光景。

3.参数的初始化:

    C++ Standard 中说过,当按值传递或者按值返回的时候,其中的操作类似下面的行为

如 X xx= arg; //其中arg 是实际参数, xx是我们在函数中看见的形参。
     想一下下面的函数调用操作会发生什么?

X xx;
void foo(X x0);
foo(xx);
    根据我们的理解,简单的传值操作嘛,调用拷贝构造函数嘛。那么看仔细了,下面的说法可能让你大吃一惊。

编译器内部的实现策略是这样的:导入临时对象,调用拷贝构造函数来初始化临时对象,然后讲将此临时对象交给函数。

伪码:↓

X temp;
temp.X::X(xx);
//函数的调用操作可能要被改写:
foo(temp);
  但是这样的方案貌似是不合适的,因为你怎么能直接操纵临时对象呢?你可并不是引用型参数哦。

  传值的调用过程会有两个问题:

  1.产生临时对象并且调用拷贝构造函数进行初始化

  2.采用bitwise 的方式,把临时对象的内容拷贝到形参中。(我们在前面讲过了,bitwise 拷贝是不需要拷贝构造函数就可以实现的,后面我们还会遇到)

  这个地方我可是有很大疑惑的。传统的传值调用,我们可是这个样子理解的:产生临时对象,然后把临时对象交给了函数处理,但是你在这个地方看到的,它竟然还有一个bitwise 方式的拷贝。这点一度让我困惑。

  后来我测试了下,似乎有点理解。正如我上面所说的,bitwise 方式的操作是不调用拷贝构造函数的,所以你根本无法通过显式的设置一个语句来检测是否发生了bitwise 方式的拷贝。如果想看,那么只能看汇编。

  但是如果我们修改一下函数的原型是不是会好很多呢?

void foo(X& x0);
    按照我们已经知道的理论来解释,这个地方是不应该产生临时对象的。但是按照上面的理论进行解释的话,你可以看到仍然会产生临时对象,但是由于参数是引用型,所以省去bitwise 的那一步,直接操作的是临时对象,那么是否对临时对象的操作会影响到我们的xx对象呢?按照我们已经有的知识去看,对引用型参数的操作是会直接改变自身的,因为我们把引用当成别名理解的。但是当我们对临时对象的操作也会改变自身嘛?其次的一个疑问是,临时对象是无名的,那么如何建立引用?这个地方有争议,我一直也没找到答案,最好的猜测是编译器动了手脚。建议大家还是按你已知的来理解。

4.返回值初始化:

    看一下如下函数:

X bar(){
X xx;
return xx;
}
    正如我们知道的,返回的不是对象xx本身,而是xx的副本拷贝。那么你知道内部是怎么进行转化的嘛。聪明的我们一定知道是调用了拷贝构造函数,因为我们自己可以很简单的测试 出来。

    我们来看一下在Cfront 编译器中的实现方式:

    进行一个双阶段的转换操作:

       1.先加上一个额外的引用型参数;

       2.在return 语句之前安插拷贝函数的调用操作。

伪码:↓

void bar(X& _result){
X xx;//声明一个局部对象的时候,会调用相应的默认构造函数进行初始操作
xx.X::X();//默认构造函数的调用操作。
_result.X::X(xx);//安插拷贝函数的调用操作
return; //这个地方就直接返回了。
}
    真正的返回值是什么呢?可以看到是不返回任何东西的,直接一个return 语句 结束了。

    下面像下面的这样函数调用操作会发生什么事情?

如: X xx=bar();//会发生什么呢?
我猜应该是这样的。
X xx;
void bar(xx);
//此处省略一万行。
    可以清楚的看见 转化了吧。

    当有函数指针的时候也会发生转化:

如:X (*pf)(); 
pf=bar;
   可能的转化 操作:

void (*pf)(X&);
pf=bar; //转化的时候多了一个引用型参数。
   看完上面的伪码,应该可以知道为什么要调用拷贝构造函数,何时调用

5.在使用者层面做优化:

    这个是一个程序员的优化操作,的确很新奇,我在学习的时候遇到过,当时只会用,却从来不知道缘由。

    这个是某位大神提出的。定义一个计算用的 Cstror.

   示例:

X bar(const X& p1, const X& p2){
X xx; //声明一个局部对象作为容器接收产生的新对象。
//..利用参数p1,p2生成一个xx;
return xx; //返回xx.这个应该是我们常见的。
}
//编译器内部的伪码:
void bar(X& _result, const X&p1, const X& p2){
X xx;
//..利用参数p1,p2生成一个xx;
//下面调用拷贝构造函数
_result.X::X(xx); //调用了拷贝构造函数。
return; //什么也不返回。

}

    示例:

X bar(const X& p1, const X& p2){
return X(p1 + p2);//原书是p1,p2,我感觉这+号样子也是可以接受的,表达了用p1,p2生成对象。
}
//编译器内部的伪码:
void bar(X& _result, const X&p1, const X& p2){
//下面调用拷贝构造函数
_result.X::X(p1+p2); //这个地方调用的是什么函数呢?是拷贝构造函数嘛?
return; //什么也不返回。

}
   可以看到少了一个局部对象的生成,拷贝构造函数也省略了, 是不是感觉对象是被计算出来的。

   举个具体的例子,上面的例子只是来分析。

   示例:

#include <iostream>
using namespace std;
class Base{
private:
int x, y;
public:
Base():x(0),y(0){
cout << "Using the default Constructor " << endl;
}
Base(int x_x, int y_y) :x(x_x), y(y_y){
cout << "Using the defined Constructor " << endl;
}
~Base(){
cout <<"Using the Destructor " << endl;
}
Base(const Base& p){
//memcpy(this, &p, sizeof(Base)); //一种写法。
cout << "Using the Copy Constructor " << endl;
x = p.x; y = p.y;
}
//故意声明为友元函数,让+函数你们的函数充实起来。
friend Base operator+(const Base& p1, const Base& p2){
//留意这个写法。
return Base(p1.x + p2.x, p1.y + p2.y);
}
friend Base operator-(const Base& p1, const Base& p2){
Base tmp;
tmp.x = p1.x - p2.x;
tmp.y = p1.y - p2.y;
return tmp; //对比上面的写法。
}
//能看懂下面的函数原型嘛?
friend ostream& operator<<(ostream& os, const Base& p){
os << "p.x = " << p.x << " " << "p.y = " << p.y << endl;
return os;
}
Base operator=(const Base p){
cout << "Using the assignment operator " << endl;
memcpy(this, &p, sizeof(Base)); //一种写法。
return *this;

}
};
int main(){
Base b1(2, 3);
Base b2(3, 4);
Base b3=b1+b2;
//b3 = b1+b2; //能看出初始化和赋值的区别嘛?
cout << "b3 " << b3 << endl;
system("pause");
Base b4 = b1 - b2;
cout << "b4 " << b4 << endl;
system("pause");
return 0;
}
   自己测试下应该能看出区别的。

6.编译器层面的优化:

  其实这个方面我是不想记录下来的,因为有点乱。

  示例:

X bar(){
X xx;
// ... process xx
return xx;
}
//C++ 伪码:
void bar(X &__result){
// default constructor invocation
// Pseudo C++ Code
__result.X::X(); //可以对比下上面的例子,这个地方竟然是直接用_result置换了 xx.
// ... process in __result directly
return;
}
    对照着上面的例子看起来,明显的区别,原先是要经过拷贝构造函数,现在直接用_result 替换了xx.

_result.X::X(xx);  //调用了拷贝构造函数。
__result.X::X();  //可以对比下上面的例子,这个地方竟然是直接用_result置换了 xx.
   下面这种行为就是编译器的NRV 优化。
     NRV优化的本质是优化掉拷贝构造函数,去掉它不是生成它。

     当然了,因为为了优化掉它,前提就是它存在,也就是欲先去之,必先有之,这个也就是NRV优化需要有拷贝构造函数存在的原因。

     NRV优化会带来副作用,目前也不是正式标准,倒是那个对象模型上举的应用例子看看比较好。

     极端情况下,不用它的确造成很大的性能损失,知道这个情况就可以了。
     为什么必须定义了拷贝构造函数才能进行NRV优化?

     首先它是lippman在inside c++ object mode里说的。那个预先取之,必先有之的说法只是我的思考。

     查阅资料,实际上这个可能仅仅只是cfont开启NRV优化的一个开关。

     上面关于NRV,我摘录了别人一段解释,我翻了很多资料,感觉这个解释是相对比较合理的。

7.是否需要拷贝构造函数?

    示例:

#include <iostream>
using namespace std;
class Point3D{
private:
int x, y, z;
public:
Point3D(int x_x = 0, int y_y = 0, int z_z = 0) :x(x_x),
y(y_y), z(z_z){
cout << "Using the Cstor " << endl;
}
friend ostream& operator<<(ostream& os, const Point3D& p){
os << "(" << p.x << "," << p.y << "," << p.z << ")" << endl;
return os;
}
};
int main(){
Point3D p1(2, 2, 3);
Point3D p2 = p1;
cout << p2 << endl;
return 0;
}
    你可以看到,我没用显式的定义拷贝构造函数,但是却在底下用类的对象初始化另一个对象。而且这个也不符合我们前面讲的合成构造函数的情形,具体的可以翻看前面。这个时候就是采用bitwise copy 的方式实现,根本用不到拷贝构造函数

    但是若能遇见到有大量的memberswise 操作,那么最好是显式定义一个拷贝构造函数,大前提是你的编译器能开启所谓的NRV优化。不然不如采用bitwise操作。

    你显式定义的拷贝构造函数可能是如下的:

Point3D(const Point3D& p){
//介绍两种写法。
//memcpy(this, &p, sizeof(Point3D));
//第二种写法
x = p.x;
this->y = p.y;
z = p.z;
}
   但是当你想用memcpy/memset 之类的函数,请务必记住 以下的内容。

当你的类中存在虚机制的情况下,比如虚函数,虚基类。更全面的说法是不内含任何有编译器产生的内部members,最常见的就是vptr了。

   那么你务必不要那样使用,因为你可能动了vptr的奶酪。

   示例:

#include <iostream>
using namespace std;
class Base{
public:
Base(){
memset(this, 0, sizeof(Base));
}
virtual~Base(){}
};
//关于memset函数的作用,感兴趣可以自己查一下.
//看一下Base()函数的伪码:
Base::Base(){//括号里面应该是有this指针的,默认省略了。
//编译器安插代码设置下vptr指针。这个操作必须在用户自定义代码之前。
_vptr_Base = _vtbl_Base;//让vptr指向虚表。
//接下来的操作有趣了。
memset(this, 0, sizeof(Base);
//vptr=0?你看出事情了。
}
注意正确使用内存相关的函数操作,尤其是在类中操作更要注意 ,因为编译器动了很多手脚。

8.总结:

    我一开始看见这个章节的标题是很奇怪的,程序转化语意学,我想哪里转化了?但是随着书中的一次次伪码分析,引人入胜。

    我强烈推荐大家一读。看了上面的文章,你应该很容易看见程序哪里发生转化了,哪里调用了拷贝构造函数,哪里调用默认构造函数。大家也可以自己试着写伪码。

9.参考文献:

  1. 网易博文:参考博文地址

  2.<<深度探索C++对象模型>>

End

     这个章节应该还有最后一篇笔记....